Skip to content

Expire Bins: X-Bin-TTL, lazy 404 and the hourly sweep - #8

Merged
avinoth merged 9 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task7
Oct 9, 2026
Merged

avinoth merged 9 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task7

Conversation

@avinoth

@avinoth avinoth commented Oct 9, 2026

Copy link
Copy Markdown
Owner

Implements docs/TODO.md §7. All its boxes are ticked. Stacked on #7: merge the earlier PRs first, in order.

Changes

  • src/bins/expiry.ts: resolveExpiresAt(header, config, now) follows api.md → Expiry rules.
    • Header sent: it must be a positive integer of seconds, and not above the Instance's maximum when one is set. Anything else gives 400 invalid_ttl and nothing is stored.
    • No header: the default TTL, otherwise the maximum, otherwise the Bin never expires.
  • Responses: POST returns expiresAt in its JSON body. POST, GET and PUT send X-Bin-Expires-At whenever the Bin has an Expiry.
  • Lazy 404: an expired Bin (expires_at <= now) returns 404 on GET, PUT and DELETE even before the sweep removes it.
  • remove: now deletes only live Bins. This closes the gap noted in Delete a Bin, DELETE /bins/:id #7, where a Bin expiring between the lookup and the delete got 204 instead of 404.
  • scheduled: sweepExpired runs deleteExpired(now, 500) in a loop until a batch comes back short or about 20 s have passed. It logs only the count.

Contract changes (docs/api.md)

  • Validation order: X-Bin-TTL is now checked right after the id and auth steps and before the content type. That way a bad TTL is rejected without reading the body. One effect: a request with both a bad TTL and a bad content type gets 400 invalid_ttl, not 415.
  • TTL syntax: spelled out exactly. Only ASCII digits are accepted: no sign, decimal point or exponent, and not 0 or empty. A TTL whose Expiry would fall past the latest representable timestamp is also 400 invalid_ttl; without that check it would cause a 500.

Tests

54 new tests:

  • Expiry rules: unit tests for every rule in resolveExpiresAt.
  • Invalid TTLs over HTTP: 0, -1, 1.5, abc, 1e3, empty, +5 and an oversized value, each returning 400 invalid_ttl with nothing stored.
  • Valid TTL: expiresAt = createdAt + TTL, and the header is sent.
  • Lazy 404: uses a faked clock, which does reach the worker in this runtime. expires_at == now counts as expired, and the row stays in D1 until the sweep.
  • Sweep: deletes only expired rows, loops across several batches, and stops at the time budget. The scheduled handler is also tested end to end.

Verification

pnpm check passes: typecheck, lint, and 247 tests.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX


Generated by Claude Code

claude added 8 commits October 9, 2026 04:30
… §0)

- pnpm project with dev/deploy/test/typecheck/lint/format/check/db:migrate:local scripts
- wrangler.jsonc with the DB binding, CREATE_LIMITER/WRITE_LIMITER, hourly cron and Instance vars
- Env type generated by `wrangler types` (`pnpm typegen`), so @cloudflare/workers-types isn't needed
- src/config.ts parses vars with zod, applying defaults and rejecting MAX_BIN_BYTES > 1,000,000
- GET /health, tests for /health and config parsing, Biome, CI workflow

Tests use @cloudflare/vitest-plugin, the renamed and maintained
@cloudflare/vitest-pool-workers (the old name is deprecated on npm).
AGENTS.md and TODO.md now name the new package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- migrations/0001_bins.sql: the schema drafted in docs/api.md → Storage.
- Tests apply the migrations to the local D1 through a vitest setup file
  (readD1Migrations in vitest.config.ts, applyD1Migrations in the worker).
- src/bins/repository.ts holds all SQL: insert, findLive (leaves out
  expired Bins), replaceContent (live Bins only), remove, and
  deleteExpired(now, limit) for the Expiry sweep.
- src/bins/keys.ts: Bin ids (nanoid), writeKey (kj_w_) and readKey (kj_r_)
  generation, hex SHA-256 hashKey and a constant-time matches.
- Unit tests run the repository against the real local D1 and check key
  format, prefixes and a known SHA-256 vector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- parseBinBody(request, config) in src/bins/body.ts validates in the
  api.md order: 415 unsupported_media_type, 413 payload_too_large
  (Content-Length first, then the bytes actually read, stopping as soon
  as the limit is passed), 400 invalid_json (including invalid UTF-8),
  422 invalid_top_level. It returns the body text exactly as received,
  so PUT (§5) can reuse it.
- POST /bins stores the content and the writeKey hash through the
  repository and returns 201 with Location, the X-Bin-* metadata headers
  and { id, writeKey, private, createdAt, expiresAt }. Bins are public
  with no Expiry until §4 and §7.
- Shared pieces: ApiError and errorResponse (src/errors.ts), the config
  middleware (src/context.ts), binMetadataHeaders (src/bins/metadata.ts),
  Cache-Control: no-store on every response, and the error shape for
  unknown routes.
- parseConfig now takes any object so it accepts the Worker's Env.

Contract change: docs/api.md gains a 500 internal_error row. Unexpected
errors still need the documented error shape, and the table had no code
for them; the details are neither returned nor logged because a request
can carry a writeKey.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
- GET /bins/:id returns the stored content byte-for-byte with
  Content-Type: application/json; charset=utf-8, the X-Bin-* metadata
  headers and Cache-Control: no-store.
- findLiveBin(db, id, now) in src/bins/routes.ts answers 404 not_found
  for a malformed id (checked with isValidBinId before D1 is asked), an
  unknown id or an expired Bin. PUT (§5) and DELETE (§6) can reuse it,
  and §4 adds the Private Bin check right after it in the GET handler.
- Tests: create → read round trips (object, array, empty, unicode,
  whitespace, key order, number formats, raw bytes), headers matching
  the create response with no X-Bin-Expires-At, the writeKey never sent,
  and 404s for unknown and malformed ids, including a malformed-id row
  inserted straight into D1 to prove it is never looked up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
X-Bin-Private: true on POST /bins makes a Private Bin: a kj_r_ Read Key is
returned once as readKey and only its SHA-256 is stored in read_key_hash.
Only the value "true" (any case, trimmed) counts; any other value makes a
public Bin, since the contract has no error code for this header.

GET /bins/:id on a Private Bin now needs Authorization: Bearer with the
Read Key or the Write Key (401 unauthorized without a key, 403 forbidden
with a wrong one). A public Bin ignores the Authorization header. The new
requireKey(c, bin, "read" | "write") helper in src/bins/auth.ts compares
keys in constant time, and PUT/DELETE can reuse it with "write".

docs/api.md: clarify (no change in meaning) that only "true" makes a
Private Bin and any other X-Bin-Private value makes a public one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §5. PUT needs the Write Key and checks, in the order of docs/api.md,
the id (404 for malformed, unknown or expired), then auth (401 without a
key, 403 for a Read Key or a wrong key), then the body via parseBinBody
(415, 413, 400, 422). The body is not read before auth passes.

The content is replaced through the repository: updatedAt moves forward
while createdAt, privacy and Expiry stay as they were; X-Bin-Private and
X-Bin-TTL are ignored. If the Bin disappears or expires between the
lookup and the update, the response is 404. The 200 response carries the
metadata headers and {id, private, createdAt, updatedAt, expiresAt}, and
never a key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §6. DELETE needs the Write Key and checks, in the order of
docs/api.md, the id (404 for malformed, unknown or expired, before auth),
then auth (401 without a key, 403 for a Read Key or a wrong key). The
Bin is then removed through the repository and the response is 204 with
no body. If another request deleted the Bin between the lookup and the
removal, the response is 404.

The Bin is gone immediately: a later GET or PUT gets 404, and a second
DELETE gets 404. Other Bins are untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §7. POST /bins resolves the Expiry from X-Bin-TTL and the Instance's
default/maximum TTL (src/bins/expiry.ts resolveExpiresAt), returns it as
expiresAt and X-Bin-Expires-At, and rejects a bad TTL with 400 invalid_ttl.
The scheduled handler sweeps expired Bins in batches of 500 until none are
left or 20 s have passed (sweepExpired).

remove now takes `now` and deletes only live Bins, so a Bin that expires
between the lookup and the DELETE is a 404 rather than a 204.

docs/api.md changes: the contract didn't say where X-Bin-TTL sits in the
validation order or what exactly counts as a positive integer. X-Bin-TTL is
now checked right after auth, before the content type and the body, since it
is a header and can be rejected without reading the body; the accepted
syntax (ASCII digits only) and the too-large case are spelled out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
@avinoth
avinoth changed the base branch from claude/keepjson-tasks-zdcoz5-task6 to main October 9, 2026 12:58
@avinoth
avinoth merged commit a80ce59 into main Oct 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants