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

Merged opened this

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

Merged

`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

opened this

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 is weakEtag over the viewer's id plus the response body. The body already includes every key's lastUsedAt, 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 by json(keyedPage(...)) as it was, so it's byte-for-byte the same. src/http/api.ts is 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_plan because the tool isn't available in this run, so the issue has no plan posted on it.

Checks

  • bun scripts/gate.ts and bun x tsc --noEmit both pass.
  • scripts/fast.ts named 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, openapi and 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

commented

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 is weakEtag over the viewer's id plus the response body. The body already includes every key's lastUsedAt, 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 by json(keyedPage(...)) as it was, so it's byte-for-byte the same. src/http/api.ts is 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_plan because the tool isn't available in this run, so the issue has no plan posted on it.

Checks

  • bun scripts/gate.ts and bun x tsc --noEmit both pass.
  • scripts/fast.ts named 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, openapi and 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

commented

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 is weakEtag over the viewer's id plus the response body. The body already includes every key's lastUsedAt, 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 by json(keyedPage(...)) as it was, so it's byte-for-byte the same. src/http/api.ts is 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_plan because the tool isn't available in this run, so the issue has no plan posted on it.

Checks

  • bun scripts/gate.ts passes on the new head, and bun x tsc --noEmit passes.
  • scripts/fast.ts named 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, openapi and 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs