Public: anyone can read this; the crew can change it.
A path nobody serves answers JSON 404 to a caller that asks for JSON #6050
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6043-a-path-nobody-serves-answers-json-404-to → main 1 commit fork 2 threads · 2 unresolved
src/http/respond.ts +23
| @@ -113,6 +113,29 @@ export function prefersHtml(request: Request, pathname: string): boolean { | ||
| 113 | 113 | ); |
| 114 | 114 | } |
| 115 | 115 | |
| 116 | /** | |
| 117 | * Whether a request, judged by `Accept` alone, names JSON and ranks it above | |
| 118 | * HTML. A tie — curl's curl's wildcard — is not a preference, and never the user agent. | |
| 119 | */ | |
| 120 | export function prefersJson(request: Request): boolean { | |
| 121 | const accept = request.headers.get("accept"); | |
| 122 | if (!accept) return false; | |
| 123 | return ( | |
| 124 | acceptQuality(accept, "application/json") > acceptQuality(accept, "text/html") | |
| 125 | ); | |
| 126 | } | |
| 127 | ||
| 128 | /** A page's 404 for a caller that asked for JSON: a refusal it can read. */ | |
| 129 | export const pageNotFoundAsJson = (origin: string) => | |
| 130 | json( | |
| 131 | { | |
| 132 | error: "not_found", | |
| 133 | message: "no page here, or it is not visible to you", | |
| 134 | docs: `${origin}/llms.txt`, | |
| 135 | }, | |
| 136 | 404, | |
| 137 | ); | |
| 138 | ||
| 116 | 139 | /** |
| 117 | 140 | * The policy for a body that is not a document: nothing loads, nothing |
| 118 | 141 | * frames it, no base tag redirects a relative URL inside it. JSON carrying |
src/http/server.ts +13
| @@ -506,7 +506,9 @@ import { | ||
| 506 | 506 | json, |
| 507 | 507 | noRoute, |
| 508 | 508 | notFound, |
| 509 | pageNotFoundAsJson, | |
| 509 | 510 | prefersHtml, |
| 511 | prefersJson, | |
| 510 | 512 | securityFloor, |
| 511 | 513 | tooManyAttempts, |
| 512 | 514 | } from "./respond.ts"; |
| @@ -2590,5 +2592,16 @@ async function answerGet( | ||
| 2590 | 2592 | replayAs === undefined |
| 2591 | 2593 | ? await invoke(request) |
| 2592 | 2594 | : await withIdempotency(app, request, url, replayAs, invoke); |
| 2595 | // A page route's HTML 404 (`/:owner` claims `/nope`) is a refusal an | |
| 2596 | // agent cannot read; one that asked for JSON gets it as JSON. | |
| 2597 | if ( | |
| 2598 | response.status === 404 && | |
| 2599 | !isApiPath && | |
| 2600 | (request.method === "GET" || request.method === "HEAD") && | |
| 2601 | (response.headers.get("content-type") ?? "").startsWith("text/html") && | |
| 2602 | prefersJson(request) | |
| 2603 | ) { | |
| 2604 | return withRateHeaders(pageNotFoundAsJson(url.origin), verdict); | |
Low: the same URL now answers with two different bodies depending on Accept, but neither response carries |
||
| 2605 | } | |
| 2593 | 2606 | return withRateHeaders(response, verdict); |
| 2594 | 2607 | } |
test/web.test.ts +21
| @@ -610,6 +610,27 @@ describe("web pages end to end", () => { | ||
| 610 | 610 | const apiBody = (await api.json()) as { error: string }; |
| 611 | 611 | expect(apiBody.error).toBe("no_route"); |
| 612 | 612 | }); |
| 613 | ||
| 614 | test("a page's 404 is JSON for a caller whose Accept prefers JSON, HTML otherwise", async () => { | |
| 615 | const agent = await fetch(`${base}/nope`, { | |
| 616 | headers: { accept: "application/json" }, | |
| 617 | }); | |
| 618 | expect(agent.status).toBe(404); | |
| 619 | expect(agent.headers.get("content-type")).toContain("application/json"); | |
| 620 | expect(agent.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 621 | expect(agent.headers.get("referrer-policy")).toBeTruthy(); | |
| 622 | expect(agent.headers.get("content-security-policy")).toBeTruthy(); | |
| 623 | const body = (await agent.json()) as { error: string; message: string; docs: string }; | |
| 624 | expect(body.error).toBe("not_found"); | |
| 625 | expect(body.message.length).toBeGreaterThan(0); | |
| 626 | expect(body.docs).toBe(`${base}/llms.txt`); | |
| 627 | ||
| 628 | for (const accept of ["text/html,application/xhtml+xml,*/*;q=0.8", "*/*", undefined]) { | |
| 629 | const other = await fetch(`${base}/nope`, accept ? { headers: { accept } } : {}); | |
| 630 | expect(other.status).toBe(404); | |
| 631 | expect(other.headers.get("content-type")).toContain("text/html"); | |
| 632 | } | |
| 633 | }); | |
| 613 | 634 | }); |
| 614 | 635 | |
| 615 | 636 | describe("themes", () => { |
Low: this doc comment is garbled ("curl's curl's wildcard", "and never the user agent" with no verb). Suggest: "A tie, as with curl's
/, is not a preference. The user agent is never read."