fix(edge): stop agent-edge 404ing every real /api/* route - #102
Merged
Conversation
`agent-edge.mjs` gained a `path.startsWith('/api/')` catch-all in 4c67733
that returned a JSON 404 *before* `worker.mjs` ever delegated to OpenNext.
Only `/api/ai` sat above it, so every other `GET /api/*` was answered by
the edge and never reached Next.js. Live on starboard.codevetter.com:
GET /api/discover -> 404 "Unknown API path"
POST /api/discover -> 405 (the real Next.js handler — the route exists)
GET /api/health -> 404
GET /api/auth/session, /api/auth/callback/github -> 404
That is 30 of the repo's 31 route handlers, plus the entire NextAuth
surface (session, csrf, providers, signin, the OAuth callback), plus the
four tools of the public Starboard MCP connector.
The fix inverts who decides existence. The edge keeps a pre-handler for
the surfaces it genuinely owns (`/llms.txt`, `/llms-full.txt`,
`/index.md`, `/openapi.json`, `/api/ai`, homepage markdown negotiation)
and returns `null` for everything else. The JSON-404 envelope moves to
`withApiJsonNotFound`, a post-handler applied to the response coming back
from OpenNext: only when *Next.js itself* reports 404 for an `/api/*`
path does the HTML error page get swapped for JSON. The edge never
asserts that a path does not exist, so adding a route handler under
`src/app/api/` needs no edge change and the shadowing cannot come back.
The markdown-404 branch now also skips `/api/*` — an `Accept` header must
not divert a real API request away from its handler.
Regression coverage at two levels, both proven to fail before this change:
- `src/__tests__/agent-edge-api-routing.test.ts` drives the real
`worker.mjs` with a stubbed OpenNext handler (aliased in
`vitest.config.ts`, since `.open-next/` is gitignored). The guarded
route list is read off the filesystem, so new handlers are covered the
moment they land. Before: 35 failed / 7 passed. After: 42 passed.
- `e2e/public-app.spec.ts` asserts the same through real workerd via
`wrangler dev`. Before: failed on `/api/health`. After: passed.
Needs a deploy to take effect in production.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
agent-edge.mjsgained a catch-all in 4c67733 ("Add Clarity, an OpenAPI spec, and Accept-aware caching"):worker.mjscallshandleAgentEdge(request)before delegating to OpenNext, and that handler engages for GET/HEAD with only/api/aiallow-listed above the catch-all. So every otherGET /api/*was answered by the edge and never reached Next.js. It shipped 2026-08-23 and went unnoticed for six days.Reproduction — the GET/POST split
The same path answers differently by method, which proves the route handler exists and only GETs are being swallowed at the edge:
Every route that was being shadowed
All 31
src/app/api/**/route.tshandlers were behind the catch-all;/api/aiis not one of them (it is edge-owned), so 30 of 31 real handlers were unreachable on GET. The ones that exportGETwere outright broken; the rest were shadowed but only observable as a wrong status code./api/auth/[...nextauth](session, csrf, providers, signin, signout, callback/github)/api/catalog-updates/api/discover/api/github/projects/api/growth/api/health/api/internal/embed-pending/api/lists/api/lists/public/[slug]/api/project-preview/api/projects/api/projects/[slug]/intelligence/api/projects/[slug]/recommendations/api/repos/[repoId]/api/repos/[repoId]/comments/api/repos/[repoId]/similar/api/repos/[repoId]/star-history/api/repos/[repoId]/tools/api/stars/api/tools/api/embeddings/generate/api/internal/external-reviews/ingest/api/internal/project-intelligence/run/api/lists/[id]/api/lists/[id]/share/api/projects/[slug]/api/repos/[repoId]/comments/[commentId]/vote/api/repos/[repoId]/likes/api/repos/[repoId]/list/api/repos/[repoId]/save/api/stars/syncBlast radius beyond the four MCP tools flagged in the report:
/api/auth/session,/api/auth/csrf,/api/auth/providers,/api/auth/signin, and critically/api/auth/callback/github, the OAuth redirect target. All 404'd./api/healthwas down, so any uptime/health probe pointed at it has been reporting the site as broken (or the check itself was silently mis-reading a JSON 404).sass-maker/chatgpt-connections.The fix, and why this shape
The root cause is not the allow-list being short — it is that the edge was answering a question only Next.js can answer. Any allow-list has to be re-edited every time a route is added, and nothing makes that failure loud; the bug shipped precisely because someone added the catch-all without a mechanism that notices new routes.
So this inverts who decides existence:
handleAgentEdgebecomes a strict pre-handler. It answers only for surfaces the edge genuinely owns —/llms.txt,/llms-full.txt,/index.md,/openapi.json,/api/ai(now via an explicitly namedEDGE_OWNED_API_PATHSset), and homepage markdown negotiation — and returnsnullfor everything else. It can no longer conclude that a path does not exist.withApiJsonNotFound.worker.mjscalls OpenNext first and passes the response through it on the way out. Only when Next.js itself returns 404 for an/api/*path does the HTML error page get swapped for the JSON envelope.There is still a one-entry allow-list (
EDGE_OWNED_API_PATHS), but it can no longer rot the way the old one did, because it is additive, not subtractive. It grants the edge one path; it does not deny anything. A new route handler undersrc/app/api/is reachable with zero edge changes. The only way to break a route now is to add its exact path toEDGE_OWNED_API_PATHSon purpose — and the filesystem-derived test below fails immediately if anyone does.Two smaller pieces of the same shape:
/api/*. Previously the catch-all ran first so this never triggered for API paths; with the catch-all gone, anAccept: text/markdownheader would have diverted a real API request into a markdown 404. AnAcceptheader must never stop a request from reaching its handler.withApiJsonNotFoundleaves a route handler's own JSON 404 body alone (checkscontent-type), and is scoped to GET/HEAD, matching 4c67733's original scope —POST /api/unknownstill gets the Next.js HTML 404 exactly as it does today.Everything 4c67733 intended still works: the Clarity/agent surfaces, the OpenAPI spec at
/openapi.json, Accept-aware caching,/api/ai, and a JSON (not HTML) 404 for a genuinely unknown/api/*path. All of that is asserted in the new tests.Regression tests, and the non-vacuousness proof
Two levels, both verified to fail against the current
maincode by stashing onlyagent-edge.mjs+worker.mjsand re-running.1.
src/__tests__/agent-edge-api-routing.test.ts— drives the realworker.mjsentrypoint (not a reimplementation of it) with a stubbed OpenNext handler..open-next/worker.jsis a gitignored build artifact absent in CI, sovitest.config.tsaliases that import tosrc/__tests__/fixtures/open-next-worker-stub.mjs.The guarded route list is read off the filesystem, not hard-coded — the exact rot the old allow-list suffered:
A route handler added under
src/app/api/is covered the moment it lands.BEFORE the fix — 35 failed / 7 passed
AFTER the fix:
2.
e2e/public-app.spec.ts— the same assertions through the real workerd runtime viawrangler dev, so the fix is proven in the environment that actually broke, not just in Node.BEFORE (fix stashed):
AFTER:
Verification
Ran
pnpm check,pnpm typecheck,pnpm test,pnpm docs:check,pnpm quality:unused, and thedesktopproject ofpnpm test:e2e.Pre-existing, not touched: the single
pnpm checkwarning issrc/app/layout.tsx:75— an ineffectivebiome-ignore lint/security/noDangerouslySetInnerHtmlsuppression on fleet-generated JSON-LD. It is present onmain, unrelated to this change, and left alone.Needs a deploy
This does not fix production on merge. Starboard deploys manually (
pnpm deploy:cf), sostarboard.codevetter.comkeeps 404ing everyGET /api/*— including the GitHub OAuth callback and/api/health— until someone runs a deploy. No deploy was performed as part of this PR.Follow-up outside this repo (not fixed here)
agent-edge.mjsis listed indocs/development/conventions.mdas generated by the fleet agent-edge generator. The fix here is in the generated output, so regenerating from the unfixed upstream template reintroduces the outage. I added a carry-forward note to that section of the conventions doc, and the new test fails loudly if the catch-all returns — but the generator template itself still needs the same change.The same
Unknown API pathcatch-all pattern is present across the fleet (calorie/src/agent-edge.mjs,free-ai/src/agent-edge.mjs,anime-list/src/agent-edge.mjs,everythingrated/apps/web/agent-edge.mjs,codevetter/apps/landing-page-astro/worker.mjs, and thefunctions/_middleware.tsvariants in ~14 more repos). Any of those fronting an app with real/api/*routes has the same bug. I did not touch sibling repos; this is worth a fleet-wide sweep.🤖 Generated with Claude Code