Public: anyone can read this; the crew can change it.

GET /api/v1/tokens carries an ETag and answers a repeat with 304 until a token is made, used or revoked #9209

Merged opened this

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

Merged

`GET /api/v1/tokens` now sends a weak ETag. When a request repeats that tag in `If-None-Match`, it gets a 304 with headers and no body until a token is made, used or revoked. This uses the same pattern as the `…/forks` ETag (#9172).

shipped production is serving it deploy da65c36

opened this

GET /api/v1/tokens now sends a weak ETag. When a request repeats that tag in If-None-Match, it gets a 304 with headers and no body until a token is made, used or revoked. This uses the same pattern as the …/forks ETag (#9172).

What changed

  • src/http/api/tokens.ts: the token list is turned into JSON text once (JSON.stringify(tokens), the same call json() made, so the body is byte-for-byte the same). The ETag comes from weakEtag over that text. The 200 also carries cache-control: private, no-cache and vary: authorization, cookie. If holds() matches, the answer is a 304 with those same headers and no body; the securityFloor headers are still added on the way out. src/http/api.ts is untouched.
  • Why the tag moves: the body lists every token's lastUsedAt and oauthClient, so creating, using or revoking a token, or changing its client, changes the bytes and so the tag. Two people shown different lists get different tags.
  • Who can get a 304: only a request that passes requireAccount gets a tag. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) get the same refusals as before, with no ETag, even with If-None-Match: *.

Tests: test/tokens-etag.test.ts covers:

  • the 200's headers, and that its body equals JSON.stringify(listTokensWithClients(…));
  • the 304 having no body and keeping the security headers (x-content-type-options, content-security-policy, referrer-policy);
  • the tag moving after each of: a token made over HTTP, a token's first use, an OAuth client attached, that client renamed, and the token revoked over HTTP — and each new tag giving a 304 again;
  • two people with different lists getting different tags, with one person's tag getting the other a 200;
  • an anonymous request, a Bearer token and a cross-origin cookie with If-None-Match: * getting no tag.

With the route change reverted, three of the four tests fail. The "no tag" test passes either way, because it checks behaviour that was already true.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and bun test on tokens-etag, tokens, tokens-oauth-client and web-tokens-edge (27 pass, 0 fail).

Not done: HEAD requests and the settings page's server-rendered token table get no tag; the issue asked only for the API route. The issue also asked me to keep a plan with a keep_plan tool, but that tool wasn't available in this run, so the issue has no plan.

To try it: sign in, GET /api/v1/tokens with the session cookie, then repeat it with If-None-Match: <etag> and you should get a 304. Mint or revoke a token and the repeat gets a 200 with a new tag.

🤖 Generated with Claude Code

Closes #9199

commented

GET /api/v1/tokens now sends a weak ETag. When a request repeats that tag in If-None-Match, it gets a 304 with headers and no body until a token is made, used or revoked. This uses the same pattern as the …/forks ETag (#9172).

What changed

  • src/http/api/tokens.ts: the token list is turned into JSON text once (JSON.stringify(tokens), the same call json() made, so the body is byte-for-byte the same). The ETag comes from weakEtag over that text. The 200 also carries cache-control: private, no-cache and vary: authorization, cookie. If holds() matches, the answer is a 304 with those same headers and no body; the securityFloor headers are still added on the way out. src/http/api.ts is untouched.
  • Why the tag moves: the body lists every token's lastUsedAt and oauthClient, so creating, using or revoking a token, or changing its client, changes the bytes and so the tag. Two people shown different lists get different tags.
  • Who can get a 304: only a request that passes requireAccount gets a tag. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) get the same refusals as before, with no ETag, even with If-None-Match: *.

Tests: test/tokens-etag.test.ts covers:

  • the 200's headers, and that its body equals JSON.stringify(listTokensWithClients(…));
  • the 304 having no body and keeping the security headers (x-content-type-options, content-security-policy, referrer-policy);
  • the tag moving after each of: a token made over HTTP, a token's first use, an OAuth client attached, that client renamed, and the token revoked over HTTP — and each new tag giving a 304 again;
  • two people with different lists getting different tags, with one person's tag getting the other a 200;
  • an anonymous request, a Bearer token and a cross-origin cookie with If-None-Match: * getting no tag.

With the route change reverted, three of the four tests fail. The "no tag" test passes either way, because it checks behaviour that was already true.

Checks run: bun scripts/gate.ts, bun x tsc --noEmit, and bun test on tokens-etag, tokens, tokens-oauth-client and web-tokens-edge (27 pass, 0 fail).

Not done: HEAD requests and the settings page's server-rendered token table get no tag; the issue asked only for the API route. The issue also asked me to keep a plan with a keep_plan tool, but that tool wasn't available in this run, so the issue has no plan.

To try it: sign in, GET /api/v1/tokens with the session cookie, then repeat it with If-None-Match: <etag> and you should get a 304. Mint or revoke a token and the repeat gets a 200 with a new tag.

🤖 Generated with Claude Code

commented

GET /api/v1/tokens now sends a weak ETag, and a request that repeats it in If-None-Match gets a 304 with no body until a token is made, used or revoked. It follows the same pattern as the …/forks ETag (#9172).

  • src/http/api/tokens.ts: the token list is turned into JSON once with the same call json() used, so the body is byte-for-byte unchanged. The ETag is weakEtag over that text, and the 200 also carries cache-control: private, no-cache and vary: authorization, cookie. When holds() matches, the answer is a 304 with those same headers and no body; the securityFloor headers are still added on the way out. src/http/api.ts is untouched.
  • When the tag moves: the body lists each token's lastUsedAt and oauthClient. So creating, using, revoking a token or changing its client changes the tag, and two people shown different lists never share one.
  • Who never gets a tag: only a request that passes requireAccount gets one. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) answer as before with no ETag, even with If-None-Match: *.
  • test/tokens-etag.test.ts: covers the 200's headers and its body against JSON.stringify(listTokensWithClients(…)), and the 304's empty body and security headers. It checks the tag moves after each of: a token made over HTTP, its first use, an OAuth client attached, that client renamed, and the token revoked. It also covers two people never sharing a tag and the refused requests getting none. With the route change reverted, three of the four tests fail; the "no tag" test passes either way, because that behaviour was already true.
  • Check fix: the gate rejected the first push because no commit message had a Try: line. I added an empty commit that carries one, since a force push isn't allowed. The gate, bun x tsc --noEmit and test/tokens-etag.test.ts (4 pass, 0 fail) pass after it.

Not done: HEAD requests and the settings page's own token table get no tag, because the issue asked only for the API route. The issue also asked for a plan kept with a keep_plan tool, which wasn't available in this run, so the issue has no plan.

Try: sign in on the preview and GET /api/v1/tokens with the session cookie. Repeat it with If-None-Match set to the returned etag and you get a 304. Mint or delete a token on Settings › Tokens, and the same repeat gets a 200 with a new etag.

🤖 Generated with Claude Code

Approved

Clean: tag is over the exact bytes sent, headers and 304 as specified, 401/403 paths untouched and tagless, tests cover each behaviour. Security reviewed: no cross-user tag sharing (body includes the viewer's own tokens), bearer/anon never reach the tag. Minor nit, not blocking: the new import sits before ../context.ts out of alphabetical order.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs