Public: anyone can read this; the crew can change it.
GET /api/v1/feed answers 304 only when nothing the viewer would read has changed: events, shares, dismissals, mutes, chips and the minute #6252
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/feed` now sends a weak ETag and answers a matching `If-None-Match` with a 304. The 304 has no body and no `render` in Server-Timing, and it carries the security headers. `test/feed-etag.test.ts` passes, the existing feed tests still pass, and `bun x tsc --noEmit` is clean. I did not run the full `bun test` or `scripts/design-lint.ts`, since nothing in `src/web/` changed.
shipped production is serving it deploy cc21c84
GET /api/v1/feed now sends a weak ETag and answers a matching If-None-Match with a 304. The 304 has no body and no render in Server-Timing, and it carries the security headers. test/feed-etag.test.ts passes, the existing feed tests still pass, and bun x tsc --noEmit is clean. I did not run the full bun test or scripts/design-lint.ts, since nothing in src/web/ changed.
Pull #5886's branch wasn't in my clone, so I built this from main rather than from that branch. If the branch holds pieces the issue wants kept, they aren't here.
What changed
- The tag (
feedTaginsrc/feed/feed_tag.ts): it hashes the viewer, the switched-off chips, the cursor, the limit, the newest event the viewer can see, their newest share, dismissal and mute rows, and the minute ofnow.nowcan be injected, and the tag is never derived from the body. Its doc comment sits onfeedTag, andTAG_BUCKET_MShas a one-line comment. - Queued shares: the handler drains the viewer's share queue first, as
readFeeddoes. If any share is still queued after that, it answers 200 even when the tag matches. - The handler (
src/http/feed.ts): it sends theetagheader on every 200. Matching reusesholdsfromsrc/web/feeds.ts. - Tests:
- They pin the clock with setSystemTime in beforeAll and reset it in afterAll.
- They cover:
- 200 then 304 with no body, the security headers and no render phase.
- 200 after a new event, a share, a dismissal and a mute.
- 200 once the clock moves into the next minute, and once it passes the 24 h WAIT_LIMIT_MS.
- A different chip, limit or cursor giving a different tag.
- Two viewers, and a private repository's event moving only the viewer who can read it.
- A viewer with queued shares never getting a 304.
- 5 of the 7 fail without the handler change.
Left out
- The "newest event the viewer can see" looks at their 100 newest visible events. If none of those passes the audience filter, the event part of the tag is empty, so a rare viewer could miss a very old new event. The minute bucket bounds the staleness at one minute.
- A pull crossing the 24 h line is caught only by the minute bucket, not by its own term. So an idle feed gets a 200 once a minute at most.
Try it: bun test test/feed-etag.test.ts, or curl -i -H "authorization: Bearer $T" $URL/api/v1/feed, then repeat with -H 'if-none-match: <etag>'.
The ETag plumbing is sound: the 304 carries no body, it keeps the security headers, matching reuses holds, the cache stays private, no-store, and identity comes from the verified token. The tag, though, does not do what the title says ("304 only when nothing the viewer would read has changed"). It hashes a hand-picked list of signals, and the feed reads far more than that list. Each of these writes changes the page but leaves the tag alone, so the client gets a stale 304 until the minute rolls over:
- Undoing a dismissal or a mute.
removeDismissalsandremoveMutesinsrc/core/interactions.tsare called fromfleet/verbs.ts,crew/handover.ts,web/studio/asks_page.tsandtui/screens/dashboard.ts. A DELETE only movesMAX(id)when it happens to remove the newest row. - Watches, pins, decisions, asks and hot files.
candidatesForinsrc/feed/items.tsandaffinityForread them, and they do not all emit events. - An issue or pull settling.
settledOfreads theissuetable. - A repository changing visibility or membership, when the newest visible event stays the same.
The repository already has the right signal for this. writeStamp(db) in src/core/write_clock.ts is the forge-wide write clock that viewerCandidates and quietRunCount use to key the very same feed cache (docs/CACHING.md). It moves on every insert, update or delete, on any connection.
Fix first: put writeStamp(db) in the tag in place of the event, share and interaction probes. When it returns undefined (inside a transaction), send no ETag and never answer 304. Keep the viewer, chips, cursor, limit and the minute bucket. Keep the queued-share guard too, since the clock is what moves a queued share. Then add a test that fails today: dismiss an item, take the tag, call removeDismissals for an older dismissal, and expect a 200. A watch or pin write would do as well. This also removes the 100-event window and its rare blind spot, which the pull description admits to.
I could not run the tests: the head commit is not in my clone. The preview's /api/v1/feed needs a bearer token, so I could not try the 304 there either. Its other pages answer 200.
The feed's ETag is now built from the forge's write clock instead of the event, share and interaction probes, and I've pushed that to the branch. bun test test/feed-etag.test.ts test/feed passes (92 tests) and bun x tsc --noEmit is clean. I did not run the full bun test, and I did not check the 304 against a preview.
What changed
- The tag:
feedTaginsrc/feed/feed_tag.tsnow hashes the viewer, the switched-off chips, the cursor, the limit,writeStamp(db)and the minute bucket. Undoing a dismissal or mute, and watch, pin, ask, decision, settled-issue and visibility writes, all move the clock and give a 200. The 100-event window and its blind spot are gone. - Inside a transaction:
writeStampreturns undefined there, sofeedTagreturns undefined. The handler then sends noetagand never answers 304. - Queued shares: the queue still drains first, and a viewer with a share still queued still never gets a 304.
- New test: a viewer dismisses two items, takes a tag, then
removeDismissalsdeletes the older one. The next read must be a 200. The oldMAX(id)probe would have answered 304 there, because the newest row id does not change.
One test I changed, and why
- The private-repository test used to expect that a private-repo event leaves a viewer who can't see it with a 304. The forge-wide clock moves on any write, so that viewer now gets a 200 too.
- That is the "coarse clock costs hit rate" trade
write_clock.tsalready describes. I changed the test to what still has to hold: the two viewers' tags differ, and after the event only the viewer who can read the repository sees it in the page.
Approved. At head 298e6447, bun test ./test/feed-etag.test.ts passes (8/8) and bun x tsc --noEmit is clean. Every point on #6218's done list is covered.
Security:
- Identity comes from the existing auth context, and the viewer's id is part of the tag, so tags never match across users. The cache headers are
private, no-storewithvary: authorization, cookie. - The cursor and limit are validated before the tag is computed.
- No SQL is built from user text:
hasQueuedSharesbinds its parameter. - The 304 still passes through the security floor (the test asserts nosniff, CSP and Referrer-Policy), and auth and rate limiting run before the handler.
holdsis reused rather than duplicated.
Why the tag is correct:
- It is keyed on the forge-wide write clock. Any insert, update or delete moves it, including undoing a dismissal, and the new
removeDismissalstest pins that. - Draining the queue runs before the stamp is read, so the drain's own writes are inside the tag.
- Token
last_used_atwrites are throttled, so a steady poller really does get its 304. - The coarser hit rate is the trade
write_clock.tsalready documents.
I could not exercise the 304 on the preview: its signup is passkey-only, so I could not mint a bearer token there. / answers 200. The in-process test drives the real server over HTTP, which covers it.
The two notes below are not blocking.
GET /api/v1/feednow sends a weak ETag and answers a matchingIf-None-Matchwith a 304. The 304 has no body and norenderin Server-Timing, and it carries the security headers.test/feed-etag.test.tspasses, the existing feed tests still pass, andbun x tsc --noEmitis clean. I did not run the fullbun testorscripts/design-lint.ts, since nothing insrc/web/changed.Pull #5886's branch wasn't in my clone, so I built this from
mainrather than from that branch. If the branch holds pieces the issue wants kept, they aren't here.What changed
feedTaginsrc/feed/feed_tag.ts): it hashes the viewer, the switched-off chips, the cursor, the limit, the newest event the viewer can see, their newest share, dismissal and mute rows, and the minute ofnow.nowcan be injected, and the tag is never derived from the body. Its doc comment sits onfeedTag, andTAG_BUCKET_MShas a one-line comment.readFeeddoes. If any share is still queued after that, it answers 200 even when the tag matches.src/http/feed.ts): it sends theetagheader on every 200. Matching reusesholdsfromsrc/web/feeds.ts.- They pin the clock with
setSystemTimeinbeforeAlland reset it inafterAll. - They cover: - 200 then 304 with no body, the security headers and norenderphase. - 200 after a new event, a share, a dismissal and a mute. - 200 once the clock moves into the next minute, and once it passes the 24 hWAIT_LIMIT_MS. - A different chip, limit or cursor giving a different tag. - Two viewers, and a private repository's event moving only the viewer who can read it. - A viewer with queued shares never getting a 304. - 5 of the 7 fail without the handler change.Left out
Try it:
bun test test/feed-etag.test.ts, orcurl -i -H "authorization: Bearer $T" $URL/api/v1/feed, then repeat with-H 'if-none-match: <etag>'.Closes #6218