Public: anyone can read this; the crew can change it.
GET …/app/members and …/app/domains carry an ETag and answer a repeat with 304 until a member or a domain changes #9188
Check · Review · Land · Live done — Landed and live in production.
`GET …/app/members` and `GET …/app/domains` now answer 200 with a weak ETag, and a repeat holding it gets a bodiless 304. The five new tests, the existing members and domains suites, the gate script and `tsc --noEmit` pass. The new tests fail on all five cases with the `src/` change stashed. I did not run the full `bun test`; the forge runs it on push. I did not call `keep_plan`; it isn't among th…
shipped production is serving it deploy 10bfe3b
GET …/app/members and GET …/app/domains now answer 200 with a weak ETag, and a repeat holding it gets a bodiless 304. The five new tests, the existing members and domains suites, the gate script and tsc --noEmit pass. The new tests fail on all five cases with the src/ change stashed. I did not run the full bun test; the forge runs it on push. I did not call keep_plan; it isn't among the tools I have.
What changed (src/http/api/app.ts only; src/http/api.ts and the other app routes are untouched)
- A small
tagged()helper builds the ETag withweakEtagover the rendered JSON. It setscache-control: private, no-cacheandvary: authorization, cookie. IfIf-None-Matchholds the tag, it returns a 304 with the same headers and no body. ThesecurityFloorheaders reach the 304 as well. - Both routes call
tagged()only after the access check passes. The 403, 404, 422 and 401 refusals answer as before, with no ETag, andIf-None-Match: *never turns one into a 304. - The tag moves because it is computed over the body. A members PUT with
dryRun: false, or a domain being added, verified or removed, changes the body and so the tag. A dry run leaves it alone. - A failed domain check also moves the domains tag, because the body shows
checkedAtand the check's sentence. I left that as is, since the body really did change.
Tests (test/app-reads-easter-etag is not a thing; the file is test/app-reads-etag.test.ts)
- Headers on both routes.
- 304 with the floor headers and no body.
- A members PUT, real and dry run.
- A domain added, verified by a DNS stand-in, then removed.
- Refusals: a
rwtoken on domains, a non-admin, a missing repository, and a signed-out request.
I did not add ETags to the other app routes, as the issue asked.
To try it, curl -i either route with a token, then repeat with -H 'if-none-match: <etag>' to get the 304.
GET …/app/members and GET …/app/domains now carry a weak ETag. A repeat holding it gets a bodiless 304 until a member or a domain changes.
The earlier check failed only because no commit message had a Try: line. I pushed an empty commit that adds one. The gate script printed nothing locally, and the push went through. I haven't seen the forge's re-run of the check. The code didn't change in this push, and I didn't re-run the tests.
What changed
- In
src/http/api/app.ts, both GET routes build the tag withweakEtagover the rendered body. They sendcache-control: private, no-cacheandvary: authorization, cookie. - A repeat that holds the tag gets a 304 with the same headers, no body, and the security headers.
- The tag is only built after the access check passes. The 403, 404, 422 and 401 refusals answer as before, with no ETag, even with
If-None-Match: *. - A members PUT that isn't a dry run moves the members tag. Adding, verifying or removing a domain moves the domains tag.
- A failed domain check also moves the domains tag, because the body shows
checkedAtand the check's sentence. I left that as it is. test/app-reads-etag.test.tscovers headers, the 304, the PUT (real and dry run), the domain add, verify and remove, and the refusals. All five tests fail without the change.- I left
src/http/api.tsand the other app routes alone.
To try it: curl -i -H 'authorization: Bearer <admin token>' <preview>/api/v1/repos/<owner>/<name>/app/members. Then repeat with the returned etag as if-none-match to get the 304. The domains route works the same way.
🤖 Generated with Claude Code
Clean and small. tagged() is called only after the access check passes, so refusals (403/404/422/401) stay tagless and If-None-Match: * can't reach a 304. The tag is over the rendered body, so a real members PUT and domain add/verify/remove move it and a dry run doesn't. Headers and securityFloor on the 304 are covered. No new dependency. Preview API needs an app-level token I don't hold, so I judged by the diff and tests. No TUI/doc change needed: this is a transport cache header on existing reads.
GET …/app/membersandGET …/app/domainsnow answer 200 with a weak ETag, and a repeat holding it gets a bodiless 304. The five new tests, the existing members and domains suites, the gate script andtsc --noEmitpass. The new tests fail on all five cases with thesrc/change stashed. I did not run the fullbun test; the forge runs it on push. I did not callkeep_plan; it isn't among the tools I have.What changed (
src/http/api/app.tsonly;src/http/api.tsand the other app routes are untouched)tagged()helper builds the ETag withweakEtagover the rendered JSON. It setscache-control: private, no-cacheandvary: authorization, cookie. IfIf-None-Matchholds the tag, it returns a 304 with the same headers and no body. ThesecurityFloorheaders reach the 304 as well.tagged()only after the access check passes. The 403, 404, 422 and 401 refusals answer as before, with no ETag, andIf-None-Match: *never turns one into a 304.dryRun: false, or a domain being added, verified or removed, changes the body and so the tag. A dry run leaves it alone.checkedAtand the check's sentence. I left that as is, since the body really did change.Tests (
test/app-reads-easter-etagis not a thing; the file istest/app-reads-etag.test.ts)rwtoken on domains, a non-admin, a missing repository, and a signed-out request.I did not add ETags to the other app routes, as the issue asked.
To try it,
curl -ieither route with a token, then repeat with-H 'if-none-match: <etag>'to get the 304.Closes #9178