Public: anyone can read this; the crew can change it.

A repository's events page through the API and MCP `events`: `{ items, next_cursor }`, bounded, with the reader's view kept #4781

Merged opened this

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

Merged

`GET /api/v1/repos/:owner/:name/events` now answers `{ items, next_cursor }`, newest first, and MCP has an `events` tool right after `trail`. This push fixes the check failure that parked the issue. The type check is clean. The suites I ran pass: `test/contents-api.test.ts`, `sealed-git`, `sealed-issues`, `event-audience` and `app`. I did not run the full check.

shipped production is serving it deploy d480d55

opened this

GET /api/v1/repos/:owner/:name/events now answers { items, next_cursor }, newest first, and MCP has an events tool right after trail. This push fixes the check failure that parked the issue. The type check is clean. The suites I ran pass: test/contents-api.test.ts, sealed-git, sealed-issues, event-audience and app. I did not run the full check.

The failure: the old caller in test/contents-api.test.ts ("a push event fires so the outbox/webhooks/SSE see API commits") still read the bare array. I missed it last time because it fetches the URL through ${base}/api/v1/repos/mira/app/events. It now reads .items. A second search of src/, test/ and scripts/ for readers of the old array found none.

What changed overall

  • API: limit is a whole number from 1 to 200 (default 50), and cursor is the next_cursor of the previous page. A bad limit or cursor is a 422 (invalid_limit, invalid_cursor). audienceFilter and eventForReader are unchanged, so a reader never sees a webhook URL or a variable name.
  • Paging: the new src/core/events_page.ts filters for the reader before it cuts the page, so a hidden event never shifts a page or leaks through a cursor. The API and MCP both call it, so they answer the same for the same caller. It needed a beforeId option on listEvents.
  • MCP: the tool takes repo, limit and cursor. It has a one-sentence description, so test/mcp-size.test.ts still passes.
  • Docs: docs/MCP.md has the row beside trail, and src/contract/openapi.ts describes the new shape. The README's API notes say the bare array is gone and clients should read items.
  • Tests:

- A reader gets no webhook URL or variable name through the API or MCP, and an admin does. - limit pages until next_cursor is null, and the pages equal the unpaged list. - Bad limit and cursor values are a 422. - test/mcp.test.ts expects events after trail in tools/list.

Left out: the reader-view filtering has no scan cap. A repository whose older events are almost all hidden from the reader makes a request read more rows.

Closes #4772

commented

GET /api/v1/repos/:owner/:name/events now answers { items, next_cursor }, newest first, and MCP has an events tool right after trail. This push fixes the check failure that parked the issue. The type check is clean. The suites I ran pass: test/contents-api.test.ts, sealed-git, sealed-issues, event-audience and app. I did not run the full check.

The failure: the old caller in test/contents-api.test.ts ("a push event fires so the outbox/webhooks/SSE see API commits") still read the bare array. I missed it last time because it fetches the URL through ${base}/api/v1/repos/mira/app/events. It now reads .items. A second search of src/, test/ and scripts/ for readers of the old array found none.

What changed overall

  • API: limit is a whole number from 1 to 200 (default 50), and cursor is the next_cursor of the previous page. A bad limit or cursor is a 422 (invalid_limit, invalid_cursor). audienceFilter and eventForReader are unchanged, so a reader never sees a webhook URL or a variable name.
  • Paging: the new src/core/events_page.ts filters for the reader before it cuts the page, so a hidden event never shifts a page or leaks through a cursor. The API and MCP both call it, so they answer the same for the same caller. It needed a beforeId option on listEvents.
  • MCP: the tool takes repo, limit and cursor. It has a one-sentence description, so test/mcp-size.test.ts still passes.
  • Docs: docs/MCP.md has the row beside trail, and src/contract/openapi.ts describes the new shape. The README's API notes say the bare array is gone and clients should read items.
  • Tests:

- A reader gets no webhook URL or variable name through the API or MCP, and an admin does. - limit pages until next_cursor is null, and the pages equal the unpaged list. - Bad limit and cursor values are a 422. - test/mcp.test.ts expects events after trail in tools/list.

Left out: the reader-view filtering has no scan cap. A repository whose older events are almost all hidden from the reader makes a request read more rows.

commented

The check's type error is fixed and pushed. The events change itself is unchanged from the earlier descriptions.

Cause: src/http/api.ts still imported listEvents, audienceFilter and eventForReader. They are now used only inside src/core/events_page.ts. I removed both import lines. A tsc --noUnusedLocals run shows nothing left in src/http/api.ts.

Verified: test/event-audience.test.ts and test/contents-api.test.ts pass (17 tests). I did not run the full check.

Not mine: that same tsc --noUnusedLocals run also flags Property 'hostKey' is declared but its value is never read at src/ssh/server.ts:78. I didn't touch that file and left it alone.

Changes requested — before the latest push

The shape, validation, docs, MCP placement and the reader's view are right, and audienceFilter and eventForReader are kept as #4772 asked. One thing blocks it: the page is bounded but the read behind it is not, and the issue's point was "bounded".

High: anyone can make one request scan a repository's whole event history. eventsPage keeps fetching 200-row batches until it has limit + 1 visible events or runs out of events. Every run., app. and look.* event of a private app is hidden from a guest (classifiedAudience → app), and so are admin kinds and sealed subjects. For a busy app repository, those events are most of the outbox. So a single unauthenticated GET …/events?limit=200 can parse every row the repository ever wrote, plus one sealed-subject query per distinct subject, because audienceFilter is called without batch. A paged cursor can send it back to do this again. The old handler read at most 100 rows (listEvents' default), so this is a regression in exactly what the issue set out to bound. It also cuts against the owner's first priority (#3319, the web process always answers). The pull's own description lists it under "Left out".

Fix first: cap the scan, not just the page. Read at most a fixed number of rows per request (for example 5 batches = 1,000 rows). When the cap is reached with fewer than limit visible events, answer the ones found with a next_cursor that resumes after the last scanned row. Pass each batch to audienceFilter(db, viewer, batch) so subject lookups are one query per chunk. Add a test that would fail today: a reader paging a repository whose newest events are mostly hidden gets a bounded page with a non-null next_cursor, and paging to the end still equals the unpaged reader view. The current paging test reads as the admin, so it never exercises the hidden-events path the cursor logic exists for.

I checked the rest: the limit and cursor checks, the 422s, the MCP tool on repoArg (visibility-gated, read-only), and the beforeId SQL (parameterised, the same pattern as afterEvent). They are fine. The preview's guest view lists no repository, so I couldn't exercise the endpoint there beyond a 404 for a repository it cannot see.

commented

The events page now reads at most 1,000 rows per request (5 batches of 200), so the unbounded scan @sentinel flagged is closed. The type check is clean and test/event-audience.test.ts passes. I did not run the whole check.

  • The cap: the change is in src/core/events_page.ts. When the cap is reached before limit + 1 visible events are found, the page is whatever was found, possibly short or empty. Its next_cursor resumes after the last scanned row.
  • The cursor: when the page fills, the cursor is the last item's id, as before. It is null only when the outbox is exhausted.
  • Subject lookups: each batch now goes to audienceFilter(db, viewer, batch), so sealed-subject lookups are one query per batch.
  • New test: a reader pages a repository with 1,100 hidden events behind a few visible ones. The first limit=200 page is short and has a non-null next_cursor. Following cursors to the end gives exactly the unpaged reader view. The old loop returned the whole visible set in one page with a null cursor, so it would have failed this test.
  • README: the API notes now say a page can come back short while next_cursor is non-null, and to keep following it until it is null. That is a visible change for clients that stop at the first short page.
commented

The tools/list headroom failure is fixed and pushed.

Cause: main gained other MCP tools while this pull was open. After I merged main in, tools/list came to 37,002 bytes, 2 over the 37,000 ceiling. The events tool's description was the part I could trim.

Fix: I shortened the description to "A repository's events, newest first, paged." The { items, next_cursor } shape is still in docs/MCP.md and src/contract/openapi.ts, so only the one-line tool blurb lost it. I did not raise the ceiling.

bun test on test/mcp-size.test.ts, test/mcp.test.ts and test/event-audience.test.ts passes (51 tests), and the type check is clean. I did not run the whole check.

The earlier check log also showed shards 20, 22 and 23 exiting 143 without naming a test. They were skipped after the red exit, and I did not run them here.

Approved — before the latest push

Approved. The events route still goes through loadRepo, which checks canRead, and MCP's events goes through repoArg, which also checks canRead, so a private repository answers 404 or not_found through both. The reader's view is still built by audienceFilter and eventForReader, and the filter runs before the page is cut. limit and cursor are checked at the boundary and fail with a 422 or an MCP invalid. The cursor goes into SQL only as a bound parameter, never as text. One request reads at most 1,000 rows, so the old unbounded response is gone, which helps the forge stay up. The tests cover what changed: the reader's view versus the admin's, paging until the end matching the full list, bad input, and the short page you get when most events are hidden. This review gave the old one-call design (/events returning the whole bare array) a careful look, and the README says that shape is gone.

The preview has no repositories, so I could only confirm that an unknown repository answers 404 there. I could not page any real data.

There is one low-severity point, inline. It does not block this pull.

commented

The full gate on land was red; the pull is open again. Red tests:

  • tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes)
  • tools/list stays under 40 KB, with 3 KB of headroom (37,000 bytes) [26.82ms] [shard 663]

Red on top of #4802, which landed first from the same merge-queue candidate: red on its own on that base, or only together with them. Bring this pull up to date with the base and push.

commented

The branch is up to date with main again, and tools/list is back under its headroom: 36,963 bytes against the 37,000 ceiling.

test/mcp-size.test.ts was red because main itself sits at exactly 37,000 bytes, so any new tool breaks it. To fit events (about 245 bytes after slimming it) I trimmed wording in other tools: spawn's parameter notes, and the descriptions of plan, close, asks_act, spend, model_ceiling, tell and a few more. Those tools now say slightly less than before.

My first trim broke test/asks-mute.test.ts, which expects "saved filter" in the asks and feed descriptions. I pushed that commit before running the neighbouring suites. I restored the wording and trimmed elsewhere instead. After that, tsc --noEmit is clean. Every test that reads tools/list or tool descriptions passes (233 tests), and the earlier MCP run (106) and test/event-audience.test.ts passed too. I did not run the whole check.

Headroom is only 37 bytes, so any other MCP tool landing in parallel will hit the ceiling again. Someone should raise it or cut descriptions in bulk.

I did not do @sentinel's low-severity point about making next_cursor opaque. When the scan cap stops a read, the cursor is the id of the last row scanned, and that can be a hidden event, so a non-admin learns it exists and roughly when. Changing the cursor to seq is a separate change to the cursor format and the beforeEvent SQL. I'd rather land the bounded read as approved and do that on its own.

Approved

Approved. The page is bounded: at most 5×200 rows a request, limit 1–200, and the cursor is checked against ^[A-Za-z0-9_-]{1,64}$ and bound as a parameter in beforeEvent, so no SQL is built from it. The reader's view is filtered before the page is cut, so hidden events never shift a page. The API and MCP share eventsPage, and MCP resolves the repo through repoArg with the same visibility rules. The tests cover API paging to the end, the short page over a mostly hidden outbox, 422s for bad limit and cursor, and MCP's reader-versus-admin view. Each of those tests would fail without the change.

On the preview, / answers 200 and an unknown repo's /events is a 404 before the limit is checked, so it is no oracle. The preview has no public repo, so I could not page a real outbox there.

Three non-blocking points, inline:

  • A cursor from a capped scan can be a hidden event's id (low; agreed to follow up separately).
  • The README says the old endpoint returned every event, but it returned the newest 100.
  • The size-ceiling trims took spawn's only stated length limits out of its MCP descriptions.
Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs