Public: anyone can read this; the crew can change it.
Public page defects for guests: /flow redirects to a /boards 404, doubled forge title suffix, JSON 404 on the releases page, slow openapi.json #6733
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6731-public-page-defects-for-guests-flow-redi → main 4 commits fork 4 threads · 4 unresolved
src/contract/served.ts +12 −4
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | * origin and version, tagged by its own bytes, answered 304 to that tag. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { encodeOnce, negotiate } from "../http/compress.ts"; | |
| 6 | 7 | import { holds, weakEtag } from "../http/etag.ts"; |
| 7 | 8 | import type { Handler } from "../http/context.ts"; |
| 8 | 9 | import type { Router } from "../http/router.ts"; |
| @@ -31,9 +32,16 @@ export function openapiText(origin: string, version: string): { text: string; et | ||
| 31 | 32 | |
| 32 | 33 | export function openapiRoute(router: Router<Handler>): void { |
| 33 | 34 | router.add("GET", "/contract/openapi.json", (ctx) => { |
| 34 | const { text, etag } = openapiText(ctx.url.origin, running().version); | |
| 35 | const headers = { "cache-control": "public, max-age=300", etag }; | |
| 36 | if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers }); | |
| 37 | return new Response(text, { headers: { "content-type": "application/json", ...headers } }); | |
| 35 | const hit = openapiText(ctx.url.origin, running().version); | |
| 36 | const headers = { "cache-control": "public, max-age=300", etag: hit.etag, vary: "accept-encoding" }; | |
| 37 | if (holds(ctx.request, hit.etag)) return new Response(null, { status: 304, headers }); | |
| 38 | const type = { "content-type": "application/json", ...headers }; | |
| 39 | // Encoded once per build at the slow, small level, not per request. | |
| 40 | const encoding = negotiate(ctx.request.headers.get("accept-encoding")); | |
| 41 | if (encoding !== null) { | |
| 42 | const bytes = encodeOnce(hit.etag, new TextEncoder().encode(hit.text), encoding); | |
| 43 | return new Response(bytes as Uint8Array<ArrayBuffer>, { headers: { ...type, "content-encoding": encoding } }); | |
| 44 | } | |
| 45 | return new Response(hit.text, { headers: type }); | |
| 38 | 46 | }); |
| 39 | 47 | } |
src/http/server.ts +5 −1
| @@ -2514,7 +2514,11 @@ async function answerGet( | ||
| 2514 | 2514 | // database read on a path that, by construction, cannot belong to a |
| 2515 | 2515 | // signed-in-only route, at the cost of the topbar reading "Sign in" |
| 2516 | 2516 | // even for someone who is. |
| 2517 | if (!isApiPath && prefersHtml(request, url.pathname)) { | |
| 2517 | // A plain GET (curl, a crawler, a link unfurler) names no type and gets the page too; | |
| 2518 | // machine paths and anyone who asks for JSON keep the JSON body. | |
| 2519 | const machinePath = /^\/(?:contract|mcp|oauth|\.well-known|metrics|healthz)(?:\/|$)|\.(?:json|git)(?:\/|$)|\.git\//.test(url.pathname); | |
Low: this is a second, inline list of machine paths. It sits next to |
||
| 2520 | const plainGet = (request.method === "GET" || request.method === "HEAD") && !machinePath && !prefersJson(request); | |
| 2521 | if (!isApiPath && (prefersHtml(request, url.pathname) || plainGet)) { | |
| 2518 | 2522 | const themeCookie = cookieValue(request, "forge_theme") ?? ""; |
| 2519 | 2523 | const anonCtx: Ctx = { |
| 2520 | 2524 | app, |
src/web/layout.ts +7 −1
| @@ -512,6 +512,12 @@ function pageDescription(options: PageOptions): string { | ||
| 512 | 512 | return line.length <= 200 ? line : `${line.slice(0, 197).trimEnd()}…`; |
| 513 | 513 | } |
| 514 | 514 | |
| 515 | /** The tab title: the forge named once, whether or not the page's own title already ends in it. */ | |
| 516 | export function tabTitle(title: string): string { | |
| 517 | const own = title.replace(/(?: · forge)+$/, ""); | |
| 518 | return own === "" || own === "forge" ? "forge" : `${own} · forge`; | |
| 519 | } | |
| 520 | ||
| 515 | 521 | export function page(options: PageOptions, status = 200): Response { |
| 516 | 522 | // A nonce lets exactly this page's <style> and <script> run while |
| 517 | 523 | // everything else stays forbidden; it is random, or bound to the body when |
| @@ -549,7 +555,7 @@ export function page(options: PageOptions, status = 200): Response { | ||
| 549 | 555 | name="viewport" |
| 550 | 556 | content="width=device-width, initial-scale=1, viewport-fit=cover" |
| 551 | 557 | /> |
| 552 | <title>${options.title === "forge" ? "forge" : `${options.title} · forge`}</title> | |
| 558 | <title>${tabTitle(options.title)}</title> | |
| 553 | 559 | <meta name="description" content="${pageDescription(options)}" /> |
| 554 | 560 | <meta property="og:site_name" content="forge" /> |
| 555 | 561 | <meta property="og:type" content="website" /> |
src/web/pages/boards-view.ts +2 −1
| @@ -15,7 +15,7 @@ import type { App } from "../../app.ts"; | ||
| 15 | 15 | import type { Ctx } from "../../http/context.ts"; |
| 16 | 16 | import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts"; |
| 17 | 17 | import { liveRegionsJs } from "../client.ts"; |
| 18 | import { notFoundPage } from "../chrome.ts"; | |
| 18 | import { notFoundPage, signInFirst } from "../chrome.ts"; | |
| 19 | 19 | import { html, when, type Html } from "../html.ts"; |
| 20 | 20 | import { fragment, page } from "../layout.ts"; |
| 21 | 21 | import { btn, card, chartBox, empty, field, selectField } from "../ui.ts"; |
| @@ -83,6 +83,7 @@ const denied = (ctx: Ctx) => { | ||
| 83 | 83 | // GET /boards |
| 84 | 84 | export async function boardsList(ctx: Ctx): Promise<Response> { |
| 85 | 85 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 86 | if (ctx.auth.user === undefined) return signInFirst(ctx); | |
| 86 | 87 | const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user)); |
| 87 | 88 | if (!listed.ok) return notFoundPage(ctx); |
| 88 | 89 | const make = html`<form method="post" action="/boards/new" class="bar"> |
test/boards-store.test.ts +4 −1
| @@ -268,7 +268,10 @@ describe("/boards", () => { | ||
| 268 | 268 | test("a non-admin and a stranger get 404 on every board route", async () => { |
| 269 | 269 | for (const path of ["/boards", "/boards/fleet-health", "/boards/fleet-health/live"]) { |
| 270 | 270 | expect((await fetch(servers.url + path, { headers: { authorization: `Bearer ${tok.bob}` } })).status).toBe(404); |
| 271 | expect((await fetch(servers.url + path)).status).toBe(404); | |
| 271 | const stranger = await fetch(servers.url + path, { redirect: "manual" }); | |
| 272 | // The list sends a stranger to sign in (#6733, so /flow is no dead end); a board itself stays a 404. | |
| 273 | if (path === "/boards") expect(stranger.headers.get("location")).toContain("next=%2Fboards"); | |
| 274 | else expect(stranger.status).toBe(404); | |
| 272 | 275 | } |
| 273 | 276 | expect((await post("/boards/fleet-health/delete", {}, "bob")).status).toBe(404); |
| 274 | 277 | expect((await post("/boards/new", { title: "x" }, "bob")).status).toBe(404); |
test/guest-crawl.test.ts added +91
| @@ -0,0 +1,91 @@ | ||
| 1 | /** A guest's crawl of the public routes: no dead redirects, one title suffix, HTML 404s (#6733). */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { compressBytes } from "../src/http/compress.ts"; | |
| 8 | import type { Ctx, Handler } from "../src/http/context.ts"; | |
| 9 | import { Router } from "../src/http/router.ts"; | |
| 10 | import { openapiRoute } from "../src/contract/served.ts"; | |
| 11 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 12 | ||
| 13 | let dataDir: string; | |
| 14 | let app: App; | |
| 15 | let servers: Servers; | |
| 16 | ||
| 17 | beforeAll(async () => { | |
| 18 | dataDir = await mkdtemp(join(tmpdir(), "forge-crawl-")); | |
| 19 | app = createApp({ dataDir, port: 0 }); | |
| 20 | servers = await serve(app); | |
| 21 | }); | |
| 22 | ||
| 23 | afterAll(async () => { | |
| 24 | servers.stop(); | |
| 25 | app.close(); | |
| 26 | await rm(dataDir, { recursive: true, force: true }); | |
| 27 | }); | |
| 28 | ||
| 29 | const PATHS = [ | |
| 30 | "/", "/flow", "/boards", "/map", "/releases", "/search", "/search?q=forge", "/login", "/explore", | |
| 31 | "/trevato/forge/issues", "/trevato/forge/pulls", "/trevato/forge/releases", "/no/such/page", "/contract/openapi.json", | |
| 32 | ]; | |
| 33 | const browser = { accept: "text/html,application/xhtml+xml;q=0.9,*/*;q=0.8" }; | |
| 34 | ||
| 35 | test("a guest never meets a dead redirect chain, a doubled title or a non-HTML 404", async () => { | |
| 36 | for (const path of PATHS) { | |
| 37 | let at = path; | |
| 38 | for (let hop = 0; ; hop++) { | |
| 39 | expect(hop).toBeLessThan(5); | |
| 40 | const res = await fetch(`${servers.url}${at}`, { headers: browser, redirect: "manual" }); | |
| 41 | const where = res.headers.get("location"); | |
| 42 | if (res.status >= 300 && res.status < 400 && where) { | |
| 43 | // Follow only paths on this server; a sign-in hand-off to the edge ends the chain. | |
| 44 | if (where.startsWith("/_plat/")) break; | |
| 45 | at = where; | |
| 46 | continue; | |
| 47 | } | |
| 48 | const body = await res.text(); | |
| 49 | const type = res.headers.get("content-type") ?? ""; | |
| 50 | if (res.status === 404) expect(type, path).toContain("text/html"); | |
| 51 | if (type.includes("text/html")) { | |
| 52 | const title = /<title>([^<]*)<\/title>/.exec(body)?.[1] ?? ""; | |
| 53 | expect(title, path).not.toContain("· forge · forge"); | |
| 54 | } | |
| 55 | if (path === "/flow" || path === "/boards") expect(res.status).not.toBe(404); | |
| 56 | break; | |
| 57 | } | |
This test sends a browser Accept header, so it passes on main. Drop the header for this request, since curl and crawlers send none, and expect text/html. |
||
| 58 | } | |
| 59 | }); | |
| 60 | ||
| 61 | test("a browser gets the HTML 404 for a repository page that does not exist, asking for HTML or naming no type, and JSON for a machine path", async () => { | |
| 62 | const bare = await fetch(`${servers.url}/trevato/forge/releases`); | |
| 63 | expect(bare.status).toBe(404); | |
| 64 | expect(bare.headers.get("content-type")).toContain("text/html"); | |
| 65 | const res = await fetch(`${servers.url}/trevato/forge/releases`, { headers: browser }); | |
| 66 | expect(res.headers.get("content-type")).toContain("text/html"); | |
| 67 | const asked = await fetch(`${servers.url}/trevato/forge/releases`, { headers: { accept: "application/json" } }); | |
| 68 | expect(asked.headers.get("content-type")).toContain("application/json"); | |
outdated
This passes on main too: the middleware already gzips JSON, and the document was already cached. The wall-clock limit can also flake. Assert something only this change produces, such as identical bytes across requests from the kept encoding, or |
||
| 69 | const machine = await fetch(`${servers.url}/mcp/no/such/thing`); | |
| 70 | expect(machine.headers.get("content-type")).toContain("application/json"); | |
| 71 | }); | |
| 72 | ||
| 73 | test("the OpenAPI document is encoded once at the kept level, in the coding the client asked for", async () => { | |
| 74 | const router = new Router<Handler>(); | |
| 75 | openapiRoute(router); | |
| 76 | const handler = router.match("GET", "/contract/openapi.json")!.handler; | |
| 77 | const get = async (coding: string) => { | |
| 78 | const url = new URL("http://forge.test/contract/openapi.json"); | |
| 79 | const request = new Request(url.href, { headers: { "accept-encoding": coding } }); | |
| 80 | const res = await handler({ request, url } as unknown as Ctx); | |
| 81 | return { res, bytes: new Uint8Array(await res.arrayBuffer()) }; | |
| 82 | }; | |
| 83 | const plain = (await get("identity")).bytes; | |
| 84 | for (const coding of ["br", "gzip"] as const) { | |
| 85 | const [a, b] = [await get(coding), await get(coding)]; | |
| 86 | expect(a.res.headers.get("content-encoding")).toBe(coding); | |
| 87 | expect(a.bytes).toEqual(b.bytes); | |
| 88 | // The per-response middleware encodes at a speed-first level; the kept copy is smaller. | |
| 89 | expect(a.bytes.length).toBeLessThan(compressBytes(plain, coding).length); | |
| 90 | } | |
| 91 | }); |
test/page-title.test.ts +9
| @@ -33,3 +33,12 @@ test("the front page is titled forge, once", async () => { | ||
| 33 | 33 | test("another page keeps its suffix", async () => { |
| 34 | 34 | expect(await page("/login")).toMatch(/<title>[^<]+ · forge<\/title>/); |
| 35 | 35 | }); |
| 36 | ||
| 37 | test("no title ends in the suffix twice", async () => { | |
| 38 | const { tabTitle } = await import("../src/web/layout.ts"); | |
| 39 | expect(tabTitle("Search · forge")).toBe("Search · forge"); | |
| 40 | expect(tabTitle("Issues · forge · forge")).toBe("Issues · forge"); | |
| 41 | expect(tabTitle("forge")).toBe("forge"); | |
| 42 | for (const path of ["/search?q=forge", "/trevato/forge/issues", "/trevato/forge/pulls"]) | |
| 43 | expect(await page(path)).not.toContain("· forge · forge"); | |
| 44 | }); |
test/web.test.ts +8 −5
| @@ -575,7 +575,7 @@ describe("web pages end to end", () => { | ||
| 575 | 575 | } |
| 576 | 576 | }); |
| 577 | 577 | |
| 578 | test("a path under no route at all gets the forge's page for a browser, and no_route for anyone else", async () => { | |
| 578 | test("a path under no route at all gets the forge's page unless the caller asks for JSON", async () => { | |
| 579 | 579 | const browser = await fetch(`${base}/ada/engine/frobnicate`, { |
| 580 | 580 | headers: { |
| 581 | 581 | accept: |
| @@ -593,12 +593,15 @@ describe("web pages end to end", () => { | ||
| 593 | 593 | expect(html).toContain("404"); |
| 594 | 594 | expect(html).toContain("Go home"); |
| 595 | 595 | |
| 596 | // No Accept header at all — a plain script or `curl` — keeps the JSON | |
| 597 | // contract, same as before this page existed. | |
| 596 | // No Accept header at all — curl, a crawler, a link unfurler — gets the page | |
| 597 | // too (#6733); a caller that asks for JSON keeps the contract. | |
| 598 | 598 | const plain = await fetch(`${base}/ada/engine/frobnicate`); |
| 599 | 599 | expect(plain.status).toBe(404); |
| 600 | expect(plain.headers.get("content-type")).toContain("application/json"); | |
| 601 | const body = (await plain.json()) as { error: string; message: string }; | |
| 600 | expect(plain.headers.get("content-type")).toContain("text/html"); | |
| 601 | const asked = await fetch(`${base}/ada/engine/frobnicate`, { headers: { accept: "application/json" } }); | |
| 602 | expect(asked.status).toBe(404); | |
| 603 | expect(asked.headers.get("content-type")).toContain("application/json"); | |
| 604 | const body = (await asked.json()) as { error: string; message: string }; | |
| 602 | 605 | expect(body.error).toBe("no_route"); |
| 603 | 606 | expect(body.message).toContain("/ada/engine/frobnicate"); |
| 604 | 607 |
Removing
brwith a regex makes brotli clients fall back to gzip. Andcompress()already encodes this response on main. UseencodeOnce(hit.etag, new TextEncoder().encode(hit.text), enc)for whichever codingnegotiatepicks, and drop the per-entrygzip?cache.