Skip to content

Send CORS headers on every response, driven by CORS_ORIGINS - #10

Merged
avinoth merged 11 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task9
Oct 10, 2026
Merged

avinoth merged 11 commits into
mainfrom
claude/keepjson-tasks-zdcoz5-task9

Conversation

@avinoth

@avinoth avinoth commented Oct 9, 2026

Copy link
Copy Markdown
Owner

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

Changes

  • src/cors.ts: Hono's cors() driven by CORS_ORIGINS, using the config parsed for each request.
    • Origins: * sends Access-Control-Allow-Origin: *. An allowlist echoes the request's Origin only on an exact match, and otherwise sends no ACAO header.
    • Methods and headers: the methods, allowed headers and exposed headers are those in api.md.
    • Credentials: never allowed, because keys travel in a header, not in cookies.
  • App-level middleware: the config and CORS middleware now run for every route. So 404s, 401s, 429s and /health all carry the CORS headers, and preflights are answered with 204 before rate limiting or D1.
  • /health and config: /health now parses the config too. It still doesn't touch D1.

Contract change (docs/api.md)

  • Retry-After is now exposed. It isn't CORS-safelisted, so without this a browser couldn't read it on a 429.
  • CORS section: now documents allowlist matching, Vary: Origin, no credentials, and how preflights are handled.

Tests

16 new tests:

  • Preflights: on /bins and /bins/:id. They aren't rate limited and don't touch D1.
  • Normal responses: CORS and expose headers on GET and POST.
  • Allowlist: an allowed origin is echoed and a disallowed one gets no ACAO, on both preflights and normal requests.
  • Errors: the headers are present on 404, 401, 429 and /health.
  • Other headers: no credentials header, and Cache-Control: no-store is kept.

Verification

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

🤖 Generated with Claude Code

https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX


Generated by Claude Code

claude added 10 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
TODO §8. CREATE_LIMITER guards POST /bins and WRITE_LIMITER guards PUT and
DELETE /bins/:id, keyed on CF-Connecting-IP. Requests without that header
share one fallback bucket ("unknown") instead of going unlimited. Over the
limit the response is 429 rate_limited with Retry-After set to the
bindings' 60 s period, since a binding can't report when its window
resets. RATE_LIMIT_ENABLED=false skips the limiter call entirely.

Contract change in docs/api.md: the rate limit is now the first step of
the validation order, ahead of route/id, auth and body checks. It exists
to protect the Worker, so a limited client should not cost a D1 lookup,
key hashing or body parsing. The Rate limiting section also documents
Retry-After and the missing-IP fallback key.

The test worker now runs with RATE_LIMIT_ENABLED=false (vitest.config.ts),
because the real Miniflare CREATE_LIMITER would start rejecting the many
Bins other test files create. test/rate-limit.test.ts calls the app
directly with stub limiter bindings and rate limiting turned on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NK16ZSCdWC5s34GE9xpLcX
TODO §9. Hono's cors() runs at the app level, after the config middleware
(now also app-level so the origin check reads the parsed CORS_ORIGINS for
each request) and before the routes. So /health, 404s, onError responses
and 429s all carry the CORS headers, and preflights are answered with 204
before the rate limiter or D1. `*` sends ACAO *; an allowlist echoes the
Origin only on an exact match. No Access-Control-Allow-Credentials, since
keys go in the Authorization header.

Contract change in docs/api.md: Retry-After is added to
Access-Control-Expose-Headers. It isn't a CORS-safelisted response header,
so without it a browser couldn't read how long to wait after a 429. The
CORS section now also spells out the allowlist matching, Vary: Origin,
the exposed headers, no credentials and the preflight behaviour.

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-task8 to main October 10, 2026 02:07
Comment thread src/cors.ts
@avinoth
avinoth merged commit 3cecd09 into main Oct 10, 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