Repository navigation
Expire Bins: X-Bin-TTL, lazy 404 and the hourly sweep - #8
Merged
Merged
Conversation
… §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
…rged Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
avinoth
changed the base branch from
claude/keepjson-tasks-zdcoz5-task6
to
main
October 9, 2026 12:58
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.400 invalid_ttland nothing is stored.expiresAtin its JSON body. POST, GET and PUT sendX-Bin-Expires-Atwhenever the Bin has an Expiry.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:sweepExpiredrunsdeleteExpired(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)X-Bin-TTLis 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 gets400 invalid_ttl, not 415.0or empty. A TTL whose Expiry would fall past the latest representable timestamp is also400 invalid_ttl; without that check it would cause a 500.Tests
54 new tests:
resolveExpiresAt.0,-1,1.5,abc,1e3, empty,+5and an oversized value, each returning400 invalid_ttlwith nothing stored.expiresAt=createdAt+ TTL, and the header is sent.expires_at == nowcounts as expired, and the row stays in D1 until the sweep.scheduledhandler is also tested end to end.Verification
pnpm checkpasses: typecheck, lint, and 247 tests.🤖 Generated with Claude Code
https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
Generated by Claude Code