fix(edge): stop agent-edge 404ing every real /api/* route on GET - #79
Merged
Merged
Conversation
`agent-edge.mjs` ran a subtractive catch-all before OpenNext:
if (path.startsWith('/api/')) {
return jsonError(404, 'not_found', `Unknown API path: ${path}`, path)
}
`handleAgentEdge` only engages for GET/HEAD, so every GET to a real
`/api/*` route was 404'd at the edge and never reached its handler, while
POST to the same path worked — the tell. Live against https://karte.cc:
/api/welcome GET=404 POST=401
/api/pages GET=404 POST=401 ← exports GET
/api/demo-chat GET=404 POST=400
/api/agent-waitlist GET=404 POST=400
/api/ai GET=200 ← edge-owned, allow-listed
21 of 58 route handlers under `src/app/api` export GET and were shadowed,
including `/api/auth/[...all]` (better-auth), `/api/pages`,
`/api/v1/agents`, `/api/settings/*` and a dozen `/api/pages/[pageId]/*`.
The edge becomes a strict pre-handler: it answers only for the paths its
additive `EXACT_ROUTES` allow-list grants it and returns `null` otherwise,
so it can never conclude a path does not exist. The JSON-404 envelope
moves to `withApiJsonNotFound`, a post-handler applied in `worker.mjs` to
the response coming back from OpenNext — only Next.js's own 404 for an
`/api/*` path becomes JSON, and a route handler's own JSON 404 is left
alone. `/api/*` is also excluded from the markdown/Accept branch so an
Accept header cannot divert a real API request.
New routes now work with zero edge changes, which is why this shape cannot
rot the way the hand-maintained allow-list did.
`tests/agent-edge-api-routing.unit.test.mjs` drives the real `worker.mjs`
entrypoint against a stubbed OpenNext handler and reads the route list off
the filesystem, so newly added handlers are covered automatically. Reverting
just the edge/worker changes fails 63 of its 69 assertions.
`agent-edge.mjs` is a copied/generated fleet artifact, so
`docs/architecture/edge-worker.md` carries a note that regenerating from an
unfixed template reintroduces the outage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`pnpm quality` starts with `biome check .` and was aborting on .fleet/evidence/landing-audit/scorecard.json, which was committed unformatted in 00683c0. Because that step aborts before the test step ever runs, CI is red on main and no PR in this repo can go green. This is unrelated to the edge-routing fix in this PR and is a pure formatting change (biome check --write, no content edited). It is included here only because it blocks this PR from merging. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The new worker/OpenNext stubs under tests/fixtures are wired in through vitest.config.ts aliases rather than imported by path, so knip cannot see the reference and reported them as 1 unused file and 4 unused exports, failing `pnpm quality:unused`. knip.json already ignores tests/e2e/**; this extends the same treatment to tests/fixtures/**. Verified against main: main reports 0 unused files and 0 unused exports, so this restores that baseline rather than widening it to hide anything real. 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.mjsran a subtractive catch-all before OpenNext ever saw the request:handleAgentEdgeonly engages forGET/HEAD, so every GET to a real/api/*route was 404'd at the edge and never reached its handler. POST was unaffected — that asymmetry is the tell.Live reproduction against https://karte.cc (before this PR)
Scope
21 of 58 route handlers under
src/app/apiexportGETand were therefore shadowed in production:/api/ai/markdown/api/auth/[...all]— the better-auth catch-all, so auth flows that rely on GET are affected/api/chat/[slug]/messages/api/og/api/pages/api/pages/[pageId]/conversations/api/pages/[pageId]/conversations/[conversationId]/api/pages/[pageId]/domains/api/pages/[pageId]/emails/api/pages/[pageId]/emails/[emailId]/api/pages/[pageId]/generated-status/api/pages/[pageId]/info/api/pages/[pageId]/links/api/pages/[pageId]/opportunities/api/pages/[pageId]/projects/api/pages/[pageId]/sections/api/pages/[pageId]/timeline/api/settings/ai-key/api/settings/knowledgebase/api/v1/agents/api/v1/agents/[slug]The remaining 37 handlers export only POST/PATCH/DELETE and were unaffected — which is why the outage was invisible to the app's own write paths.
The fix
Mirrors the shape just landed for the identical defect in Codevetter/starboard#102, adapted to karte's
worker.mjswiring.handleAgentEdgeanswers only for paths itsEXACT_ROUTESmap grants it (/llms.txt,/llms-full.txt,/index.md,/robots.txt,/openapi.json,/openapi.yaml,/api/ai) and returnsnullfor everything else. It can no longer conclude that a path does not exist — only Next.js knows the route table.withApiJsonNotFound(request, response)is applied inworker.mjsto the response coming back from OpenNext. Only when Next.js itself 404s an/api/*path does the HTML error page become{"error":{"code":"not_found",…}}. A route handler's own JSON 404 body is left untouched./api/*is excluded from the markdown/Accept branch, so anAccept: text/markdownheader can never divert a real API request to the agent markdown 404.worker.mjshas exactly two call sites an/api/*request can reach (the non-GET branch — which also carries HEAD — and the non-cacheable-document branch; API paths are never inCACHEABLE_EXACT), and both are wrapped.Why this shape cannot rot
The old allow-list was subtractive: it denied everything under
/api/except a hand-maintained set, so any route added later was born broken and nothing in the repo noticed. The new arrangement is additive — the edge is granted specific paths and defers on all others — and the 404 decision is delegated to the only component that actually knows the route table. Adding a handler undersrc/app/api/makes it reachable with zero edge changes. On top of that, the regression test enumerates the route list off the filesystem (it.each(API_ROUTE_PATHS)over areaddirSyncwalk ofsrc/app/api), so a newly added handler is covered the moment it lands — no second hand-maintained list to rot.Regression test
tests/agent-edge-api-routing.unit.test.mjsdrives the realworker.mjsentrypoint (not a reimplementation) against a stubbed OpenNext handler.vitest.config.tsaliases./.open-next/worker.js(a gitignored build artifact) andcloudflare:workersto fixtures undertests/fixtures/.It asserts every discovered API route is reachable through the edge on GET, that HEAD works, that an
Acceptheader cannot divert one, that an unknown/api/*path still yields the JSON 404, that a handler's own JSON 404 survives, that non-API 404s still render HTML, and that all edge-owned surfaces (/api/ai,llms.txt,robots.txt, homepage markdown negotiation, markdown 404) still work.Non-vacuousness proof
With the test in place and only the
agent-edge.mjs+worker.mjschanges stashed:With the fix restored:
(
expected [] to deeply equal [...]= the stub OpenNext handler was never called, i.e. the edge swallowed the request.)No Playwright test was added: karte's e2e suite runs against
next dev(playwright.config.tswebServer), which does not putworker.mjsin the request path at all, so an e2e assertion there would have passed against the broken code and proved nothing.Verification
pnpm typecheckpnpm test(vitest)pnpm docs:checkdocs/archive/)pnpm lint(biome check .)main, in.fleet/evidence/landing-audit/scorecard.json(formatting), committed by 00683c0. Confirmed failing on a cleanmaincheckout; not touched here. All files in this PR are Biome-clean.Generated-file provenance
agent-edge.mjscarries the same fleet provenance as starboard's — its header says "Portable agent-edge handler — copy or generate into each product. Spec: fleet-ops/docs/agent-indexing-standard.md", and the payload is marked// biome-ignore format: generated payload from apply-agent-surfaces. Regenerating from an unfixed upstream template would silently reintroduce this outage, sodocs/architecture/edge-worker.mdgains a carry-forward note (anddocs/development/conventions.mda "don't reintroduce" entry) recording the defect and pointing at the test that fails loudly if the catch-all comes back. No generator outside this repo was modified.Karte deploys are manual (
workflow_dispatch/pnpm deploy:cf). Merging this PR does not fix https://karte.cc — everyGET /api/*will keep returning{"error":{"code":"not_found",…}}until a deploy ships this Worker. Nothing was deployed as part of this change.Post-deploy check:
pnpm qualitystarts withpnpm check(biome check .) and aborts on the.fleet/evidence/landing-audit/scorecard.jsonformatting error introduced by00683c0 on
main— so CI never reaches the test step here. The same run is redon
mainitself (run33262947516, sha00683c0, before this branch existed).Deliberately not fixed here: it is an unrelated file. Once it is formatted, this
branch's
pnpm testpasses 163/163 locally, as above.🤖 Generated with Claude Code