Public: anyone can read this; the crew can change it.
GET /api/v1/secrets/:scope carries an ETag and answers a repeat with 304 until a secret in that scope is set or removed #9211
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/secrets/:scope` now returns a weak `etag` and answers a repeat that holds it with a bodiless 304. The tag is `weakEtag` (in `src/http/etag.ts`) over the exact JSON it sends, which is only names and timestamps, never a value. The response body is byte-for-byte as before.
shipped production is serving it deploy 4183511
GET /api/v1/secrets/:scope now returns a weak etag and answers a repeat that holds it with a bodiless 304. The tag is weakEtag (in src/http/etag.ts) over the exact JSON it sends, which is only names and timestamps, never a value. The response body is byte-for-byte as before.
What to look at:
- Route: the change is in
src/http/api/secrets.tsand follows the same pattern asGET …/forks(#9172). The 200 carriescache-control: private, no-cacheandvary: authorization, cookie. The 304 carries the same headers plus thesecurityFloorheaders. - Access check first: the tag is computed only after
requireScope("admin")andlistSecretssucceed. So a token short of admin, a non-admin and an unknown scope orrefanswer exactly as before, with no ETag, even withIf-None-Match: *. - When the tag moves:
updatedAthas millisecond resolution, so setting, replacing or removing a secret changes the body and the tag. A change in another scope orrefleaves it alone. Since the tag is computed from the body, two viewers shown different bodies cannot share one. - Tests:
test/secrets-etag.test.tscovers each point in the issue. Without the route change, 4 of its 5 tests fail. The fifth, for the denial cases, passes either way because it checks that those answers stay as they are today.
Not done: src/http/api.ts is untouched, as the issue asks. The sandbox had no keep_plan tool, so the issue has no plan posted.
How it was checked: bun scripts/gate.ts and bun x tsc --noEmit pass. scripts/fast.ts named 1108 test files, over its time budget, so I ran the 13 secrets and etag suites by hand instead: 58 pass. To try it on the preview, with an admin token:
curl -i -H "authorization: Bearer $T" /api/v1/secrets/forge
curl -i -H "authorization: Bearer $T" -H 'if-none-match: <etag>' /api/v1/secrets/forge # → 304
🤖 Generated with Claude Code
GET /api/v1/secrets/:scope now returns a weak etag and answers a repeat that holds it with a bodiless 304. The tag is weakEtag over the exact JSON sent, which holds only names and timestamps, never a value. The response body is byte-for-byte as before.
- Headers: the 200 carries
cache-control: private, no-cacheandvary: authorization, cookie. The 304 carries the same headers plus thesecurityFloorheaders. - Access check first: the tag is computed only after the admin check and the scope lookup succeed. So a token short of admin, a non-admin and an unknown scope or
refanswer exactly as before, with no ETag, even withIf-None-Match: *. - When the tag moves:
updatedAtis stored to the millisecond, so setting, replacing or removing a secret in that scope orrefchanges the body and the tag. A change in another scope orrefleaves it alone. - Tests:
test/secrets-etag.test.tscovers each point in the issue. Without the route change, 4 of its 5 tests fail. The fifth checks that denials stay as they are today, so it passes either way. - Gate fix: a second, empty commit carries the
Try:line the gate requires. Force pushes are refused, so the first commit could not be amended.
src/http/api.ts is untouched, as the issue asks. The sandbox had no keep_plan tool, so no plan is posted on the issue.
bun scripts/gate.ts and bun x tsc --noEmit pass on the pushed head, and the new test file passes. scripts/fast.ts named 1108 test files, over its time budget, so before the first push I ran the 13 secrets and etag suites by hand instead (58 pass); the new commit changes no code.
Try: with an admin token, GET /api/v1/secrets/forge, then repeat it with If-None-Match set to the returned etag and get a 304 with no body. After PUT /api/v1/secrets/forge/NAME, the same repeat is a 200 with a new etag.
🤖 Generated with Claude Code
Read the full diff. The tag is computed over the exact bytes sent, only after requireScope(admin) and listSecrets succeed, so denials and unknown scopes carry no ETag, even with If-None-Match: *. The body has names and timestamps only, never a value. The 200 and 304 follow the forks pattern, and the 304 passes through the security floor, which the test checks. No new dependency, no README edit and no dead code. The tests cover each box. I did not run the preview or the tests.
GET /api/v1/secrets/:scopenow returns a weaketagand answers a repeat that holds it with a bodiless 304. The tag isweakEtag(insrc/http/etag.ts) over the exact JSON it sends, which is only names and timestamps, never a value. The response body is byte-for-byte as before.What to look at:
src/http/api/secrets.tsand follows the same pattern asGET …/forks(#9172). The 200 carriescache-control: private, no-cacheandvary: authorization, cookie. The 304 carries the same headers plus thesecurityFloorheaders.requireScope("admin")andlistSecretssucceed. So a token short of admin, a non-admin and an unknown scope orrefanswer exactly as before, with no ETag, even withIf-None-Match: *.updatedAthas millisecond resolution, so setting, replacing or removing a secret changes the body and the tag. A change in another scope orrefleaves it alone. Since the tag is computed from the body, two viewers shown different bodies cannot share one.test/secrets-etag.test.tscovers each point in the issue. Without the route change, 4 of its 5 tests fail. The fifth, for the denial cases, passes either way because it checks that those answers stay as they are today.Not done:
src/http/api.tsis untouched, as the issue asks. The sandbox had nokeep_plantool, so the issue has no plan posted.How it was checked:
bun scripts/gate.tsandbun x tsc --noEmitpass.scripts/fast.tsnamed 1108 test files, over its time budget, so I ran the 13 secrets and etag suites by hand instead: 58 pass. To try it on the preview, with an admin token:🤖 Generated with Claude Code
Closes #9200