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
Check · Review · Land · Live done — Landed and live in production.
`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
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 calljson()made, so the body is byte-for-byte the same). The ETag comes fromweakEtagover that text. The 200 also carriescache-control: private, no-cacheandvary: authorization, cookie. Ifholds()matches, the answer is a 304 with those same headers and no body; thesecurityFloorheaders are still added on the way out.src/http/api.tsis untouched.- Why the tag moves: the body lists every token's
lastUsedAtandoauthClient, 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
requireAccountgets a tag. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) get the same refusals as before, with no ETag, even withIf-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
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 calljson()used, so the body is byte-for-byte unchanged. The ETag isweakEtagover that text, and the 200 also carriescache-control: private, no-cacheandvary: authorization, cookie. Whenholds()matches, the answer is a 304 with those same headers and no body; thesecurityFloorheaders are still added on the way out.src/http/api.tsis untouched.- When the tag moves: the body lists each token's
lastUsedAtandoauthClient. 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
requireAccountgets one. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) answer as before with no ETag, even withIf-None-Match: *. test/tokens-etag.test.ts: covers the 200's headers and its body againstJSON.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 --noEmitandtest/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
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.
GET /api/v1/tokensnow sends a weak ETag. When a request repeats that tag inIf-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…/forksETag (#9172).What changed
src/http/api/tokens.ts: the token list is turned into JSON text once (JSON.stringify(tokens), the same calljson()made, so the body is byte-for-byte the same). The ETag comes fromweakEtagover that text. The 200 also carriescache-control: private, no-cacheandvary: authorization, cookie. Ifholds()matches, the answer is a 304 with those same headers and no body; thesecurityFloorheaders are still added on the way out.src/http/api.tsis untouched.lastUsedAtandoauthClient, 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.requireAccountgets a tag. Anonymous requests (401), Bearer-token requests and cross-origin cookie requests (403) get the same refusals as before, with no ETag, even withIf-None-Match: *.Tests:
test/tokens-etag.test.tscovers:JSON.stringify(listTokensWithClients(…));x-content-type-options,content-security-policy,referrer-policy);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, andbun testontokens-etag,tokens,tokens-oauth-clientandweb-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_plantool, but that tool wasn't available in this run, so the issue has no plan.To try it: sign in,
GET /api/v1/tokenswith the session cookie, then repeat it withIf-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