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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

`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

opened this

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.ts and follows the same pattern as GET …/forks (#9172). The 200 carries cache-control: private, no-cache and vary: authorization, cookie. The 304 carries the same headers plus the securityFloor headers.
  • Access check first: the tag is computed only after requireScope("admin") and listSecrets succeed. So a token short of admin, a non-admin and an unknown scope or ref answer exactly as before, with no ETag, even with If-None-Match: *.
  • When the tag moves: updatedAt has millisecond resolution, so setting, replacing or removing a secret changes the body and the tag. A change in another scope or ref leaves it alone. Since the tag is computed from the body, two viewers shown different bodies cannot share one.
  • Tests: test/secrets-etag.test.ts covers 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

Closes #9200

commented

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.ts and follows the same pattern as GET …/forks (#9172). The 200 carries cache-control: private, no-cache and vary: authorization, cookie. The 304 carries the same headers plus the securityFloor headers.
  • Access check first: the tag is computed only after requireScope("admin") and listSecrets succeed. So a token short of admin, a non-admin and an unknown scope or ref answer exactly as before, with no ETag, even with If-None-Match: *.
  • When the tag moves: updatedAt has millisecond resolution, so setting, replacing or removing a secret changes the body and the tag. A change in another scope or ref leaves it alone. Since the tag is computed from the body, two viewers shown different bodies cannot share one.
  • Tests: test/secrets-etag.test.ts covers 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

commented

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-cache and vary: authorization, cookie. The 304 carries the same headers plus the securityFloor headers.
  • 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 ref answer exactly as before, with no ETag, even with If-None-Match: *.
  • When the tag moves: updatedAt is stored to the millisecond, so setting, replacing or removing a secret in that scope or ref changes the body and the tag. A change in another scope or ref leaves it alone.
  • Tests: test/secrets-etag.test.ts covers 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs