Public: anyone can read this; the crew can change it.
GET /api/v1/user/keys carries an ETag and answers a repeat with 304 until one of the viewer's SSH keys changes #9255
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/user/keys` now sends a weak `etag` along with `cache-control: private, no-cache` and `vary: authorization, cookie`. When a client sends that etag back in `If-None-Match`, it gets a 304 with the same headers and no body.
shipped production is serving it deploy 78f82e5
GET /api/v1/user/keys now sends a weak etag along with cache-control: private, no-cache and vary: authorization, cookie. When a client sends that etag back in If-None-Match, it gets a 304 with the same headers and no body.
What changed
- The route (
src/http/api/user.ts): the etag isweakEtagover the viewer's id plus the response body. The body already includes every key'slastUsedAt, so the tag changes when a key is added, removed or used. Including the viewer's id means two viewers never share a tag. The check for a match (holds) runs only after the "no session is a 401" check and the cursor/limit validation, so a request that's refused gets the same answer as before, with no etag. The body is still built byjson(keyedPage(...))as it was, so it's byte-for-byte the same.src/http/api.tsis not touched. - The test (
test/user-keys-etag.test.ts) checks:
- the headers on the 200;
- the 304: empty body, same headers, plus the securityFloor headers (nosniff, CSP, referrer-policy);
- a 401 with no etag, even with If-None-Match: *, for both no session and an invalid token;
- two viewers get different tags;
- adding, removing, or changing a key's last-used time (directly and through touchKey) changes the tag;
- another user's new key leaves the tag alone (still a 304).
Not done
- The issue asks for a case where a token lacks the route's scope. This route doesn't require any scope, so no such token exists; the invalid-token case covers the closest real refusal.
- Run against the code without this change, 4 of the 9 tests fail. The other five pass either way, by design: the 401 cases and the other-user's-key case check that existing behaviour doesn't change.
- I couldn't call
keep_planbecause the tool isn't available in this run, so the issue has no plan posted on it.
Checks
bun scripts/gate.tsandbun x tsc --noEmitboth pass.scripts/fast.tsnamed 1125 files (about 98 minutes), too many to run here. Instead I ran every test that calls/api/v1/user/keys:keys,run-gate-doors,user-lists-page,mcp-ssh-keys,api-keys-timing,openapiand the new test. 58 pass, 0 fail.
Try it: curl -i -H "authorization: Bearer $T" …/api/v1/user/keys, then send the same request again with -H 'if-none-match: <etag>'. It should come back 304 until you add, remove or use a key.
🤖 Generated with Claude Code
GET /api/v1/user/keys now sends a weak etag along with cache-control: private, no-cache and vary: authorization, cookie. When a client sends that etag back in If-None-Match, it gets a 304 with the same headers and no body.
What changed
- The route (
src/http/api/user.ts): the etag isweakEtagover the viewer's id plus the response body. The body already includes every key'slastUsedAt, so the tag changes when a key is added, removed or used. Including the viewer's id means two viewers never share a tag. The check for a match (holds) runs only after the "no session is a 401" check and the cursor/limit validation, so a request that's refused gets the same answer as before, with no etag. The body is still built byjson(keyedPage(...))as it was, so it's byte-for-byte the same.src/http/api.tsis not touched. - The test (
test/user-keys-etag.test.ts) checks:
- the headers on the 200;
- the 304: empty body, same headers, plus the securityFloor headers (nosniff, CSP, referrer-policy);
- a 401 with no etag, even with If-None-Match: *, for both no session and an invalid token;
- two viewers get different tags;
- adding, removing, or changing a key's last-used time (directly and through touchKey) changes the tag;
- another user's new key leaves the tag alone (still a 304).
- The first check failed because no commit message had a
Try:line. Force pushes are refused, so an empty follow-up commit adds one.
Not done
- The issue asks for a case where a token lacks the route's scope. This route doesn't require any scope, so no such token exists; the invalid-token case covers the closest real refusal.
- Run against the code without this change, 4 of the 9 tests fail. The other five pass either way, by design: the 401 cases and the other-user's-key case check that existing behaviour doesn't change.
- I couldn't call
keep_planbecause the tool isn't available in this run, so the issue has no plan posted on it.
Checks
bun scripts/gate.tspasses on the new head, andbun x tsc --noEmitpasses.scripts/fast.tsnamed 1125 files (about 98 minutes), too many to run here. Instead I ran every test that calls/api/v1/user/keys:keys,run-gate-doors,user-lists-page,mcp-ssh-keys,api-keys-timing,openapiand the new test. 58 pass, 0 fail.
Try it on the preview: GET /api/v1/user/keys with a bearer token, then send the same request again with If-None-Match set to the returned etag and get a 304. Add or delete a key under /settings/keys and the repeat is a 200 again.
🤖 Generated with Claude Code
Clean. Tag is weakEtag over viewer id plus the rendered page, so it moves on add, remove or last-use and is never shared across viewers. The 304 path runs after the auth and cursor/limit checks, so refusals carry no etag. Headers match the issue; tests cover each case. The scope case is moot since the route asks for none; the invalid-token case covers the nearest refusal. No TUI or docs needed for a header change.
GET /api/v1/user/keysnow sends a weaketagalong withcache-control: private, no-cacheandvary: authorization, cookie. When a client sends that etag back inIf-None-Match, it gets a 304 with the same headers and no body.What changed
src/http/api/user.ts): the etag isweakEtagover the viewer's id plus the response body. The body already includes every key'slastUsedAt, so the tag changes when a key is added, removed or used. Including the viewer's id means two viewers never share a tag. The check for a match (holds) runs only after the "no session is a 401" check and the cursor/limit validation, so a request that's refused gets the same answer as before, with no etag. The body is still built byjson(keyedPage(...))as it was, so it's byte-for-byte the same.src/http/api.tsis not touched.test/user-keys-etag.test.ts) checks:- the headers on the 200; - the 304: empty body, same headers, plus the
securityFloorheaders (nosniff, CSP, referrer-policy); - a 401 with no etag, even withIf-None-Match: *, for both no session and an invalid token; - two viewers get different tags; - adding, removing, or changing a key's last-used time (directly and throughtouchKey) changes the tag; - another user's new key leaves the tag alone (still a 304).Not done
keep_planbecause the tool isn't available in this run, so the issue has no plan posted on it.Checks
bun scripts/gate.tsandbun x tsc --noEmitboth pass.scripts/fast.tsnamed 1125 files (about 98 minutes), too many to run here. Instead I ran every test that calls/api/v1/user/keys:keys,run-gate-doors,user-lists-page,mcp-ssh-keys,api-keys-timing,openapiand the new test. 58 pass, 0 fail.Try it:
curl -i -H "authorization: Bearer $T" …/api/v1/user/keys, then send the same request again with-H 'if-none-match: <etag>'. It should come back 304 until you add, remove or use a key.🤖 Generated with Claude Code
Closes #9243