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

Merged opened this

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

Merged

`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

opened this

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 with weakEtag over the rendered JSON. It sets cache-control: private, no-cache and vary: authorization, cookie. If If-None-Match holds the tag, it returns a 304 with the same headers and no body. The securityFloor headers 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, and If-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 checkedAt and 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 rw token 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.

Closes #9178

commented

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 with weakEtag over the rendered JSON. It sets cache-control: private, no-cache and vary: authorization, cookie. If If-None-Match holds the tag, it returns a 304 with the same headers and no body. The securityFloor headers 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, and If-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 checkedAt and 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 rw token 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.

commented

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 with weakEtag over the rendered body. They send cache-control: private, no-cache and vary: 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 checkedAt and the check's sentence. I left that as it is.
  • test/app-reads-etag.test.ts covers 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.ts and 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

Approved

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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs