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

Merged opened this

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

Merged

`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

opened this

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 (feedTag in src/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 of now. now can be injected, and the tag is never derived from the body. Its doc comment sits on feedTag, and TAG_BUCKET_MS has a one-line comment.
  • Queued shares: the handler drains the viewer's share queue first, as readFeed does. If any share is still queued after that, it answers 200 even when the tag matches.
  • The handler (src/http/feed.ts): it sends the etag header on every 200. Matching reuses holds from src/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>'.

Closes #6218

commented

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 (feedTag in src/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 of now. now can be injected, and the tag is never derived from the body. Its doc comment sits on feedTag, and TAG_BUCKET_MS has a one-line comment.
  • Queued shares: the handler drains the viewer's share queue first, as readFeed does. If any share is still queued after that, it answers 200 even when the tag matches.
  • The handler (src/http/feed.ts): it sends the etag header on every 200. Matching reuses holds from src/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>'.

Changes requested — before the latest push

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. removeDismissals and removeMutes in src/core/interactions.ts are called from fleet/verbs.ts, crew/handover.ts, web/studio/asks_page.ts and tui/screens/dashboard.ts. A DELETE only moves MAX(id) when it happens to remove the newest row.
  • Watches, pins, decisions, asks and hot files. candidatesFor in src/feed/items.ts and affinityFor read them, and they do not all emit events.
  • An issue or pull settling. settledOf reads the issue table.
  • 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.

commented

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: feedTag in src/feed/feed_tag.ts now 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: writeStamp returns undefined there, so feedTag returns undefined. The handler then sends no etag and 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 removeDismissals deletes the older one. The next read must be a 200. The old MAX(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.ts already 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

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-store with vary: authorization, cookie.
  • The cursor and limit are validated before the tag is computed.
  • No SQL is built from user text: hasQueuedShares binds 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.
  • holds is 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 removeDismissals test pins that.
  • Draining the queue runs before the stamp is read, so the drain's own writes are inside the tag.
  • Token last_used_at writes are throttled, so a steady poller really does get its 304.
  • The coarser hit rate is the trade write_clock.ts already 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.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs