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

Merged opened this in answer to

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

Merged

scout/forge:scout/6731-public-page-defects-for-guests-flow-redi → main 4 commits fork 4 threads · 4 unresolved

  • Merged. scout/6731-public-page-defects-for-guests-flow-redi was merged into main as efa2e1d.

Review

Sign in to review this change.
8 files changed +138 −13
src/contract/served.ts +12 −4
@@ -3,6 +3,7 @@
33 * origin and version, tagged by its own bytes, answered 304 to that tag.
44 */
55
6 import { encodeOnce, negotiate } from "../http/compress.ts";
67 import { holds, weakEtag } from "../http/etag.ts";
78 import type { Handler } from "../http/context.ts";
89 import type { Router } from "../http/router.ts";
@@ -31,9 +32,16 @@ export function openapiText(origin: string, version: string): { text: string; et
3132
3233 export function openapiRoute(router: Router<Handler>): void {
3334 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"));
outdated

Removing br with a regex makes brotli clients fall back to gzip. And compress() already encodes this response on main. Use encodeOnce(hit.etag, new TextEncoder().encode(hit.text), enc) for whichever coding negotiate picks, and drop the per-entry gzip? cache.

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 });
3846 });
3947 }
src/http/server.ts +5 −1
@@ -2514,7 +2514,11 @@ async function answerGet(
25142514 // database read on a path that, by construction, cannot belong to a
25152515 // signed-in-only route, at the cost of the topbar reading "Sign in"
25162516 // 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 isApiPath, and the two will drift apart as routes are added; this list already misses /contract/nope.json, which another route claims first. Next time this code is touched, name it once, for example isMachinePath(pathname) in src/http/respond.ts beside prefersJson, and give it its own test.

2520 const plainGet = (request.method === "GET" || request.method === "HEAD") && !machinePath && !prefersJson(request);
2521 if (!isApiPath && (prefersHtml(request, url.pathname) || plainGet)) {
25182522 const themeCookie = cookieValue(request, "forge_theme") ?? "";
25192523 const anonCtx: Ctx = {
25202524 app,
src/web/layout.ts +7 −1
@@ -512,6 +512,12 @@ function pageDescription(options: PageOptions): string {
512512 return line.length <= 200 ? line : `${line.slice(0, 197).trimEnd()}…`;
513513 }
514514
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
515521 export function page(options: PageOptions, status = 200): Response {
516522 // A nonce lets exactly this page's <style> and <script> run while
517523 // 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 {
549555 name="viewport"
550556 content="width=device-width, initial-scale=1, viewport-fit=cover"
551557 />
552 <title>${options.title === "forge" ? "forge" : `${options.title} · forge`}</title>
558 <title>${tabTitle(options.title)}</title>
553559 <meta name="description" content="${pageDescription(options)}" />
554560 <meta property="og:site_name" content="forge" />
555561 <meta property="og:type" content="website" />
src/web/pages/boards-view.ts +2 −1
@@ -15,7 +15,7 @@ import type { App } from "../../app.ts";
1515 import type { Ctx } from "../../http/context.ts";
1616 import { notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
1717 import { liveRegionsJs } from "../client.ts";
18 import { notFoundPage } from "../chrome.ts";
18 import { notFoundPage, signInFirst } from "../chrome.ts";
1919 import { html, when, type Html } from "../html.ts";
2020 import { fragment, page } from "../layout.ts";
2121 import { btn, card, chartBox, empty, field, selectField } from "../ui.ts";
@@ -83,6 +83,7 @@ const denied = (ctx: Ctx) => {
8383 // GET /boards
8484 export async function boardsList(ctx: Ctx): Promise<Response> {
8585 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
86 if (ctx.auth.user === undefined) return signInFirst(ctx);
8687 const listed = await timePhaseAsync(ctx.request, "boards", () => listAllBoards(ctx.app, ctx.auth.user));
8788 if (!listed.ok) return notFoundPage(ctx);
8889 const make = html`<form method="post" action="/boards/new" class="bar">
test/boards-store.test.ts +4 −1
@@ -268,7 +268,10 @@ describe("/boards", () => {
268268 test("a non-admin and a stranger get 404 on every board route", async () => {
269269 for (const path of ["/boards", "/boards/fleet-health", "/boards/fleet-health/live"]) {
270270 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);
272275 }
273276 expect((await post("/boards/fleet-health/delete", {}, "bob")).status).toBe(404);
274277 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 br honoured with a cached body, and drop the timing check.

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 () => {
3333 test("another page keeps its suffix", async () => {
3434 expect(await page("/login")).toMatch(/<title>[^<]+ · forge<\/title>/);
3535 });
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", () => {
575575 }
576576 });
577577
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 () => {
579579 const browser = await fetch(`${base}/ada/engine/frobnicate`, {
580580 headers: {
581581 accept:
@@ -593,12 +593,15 @@ describe("web pages end to end", () => {
593593 expect(html).toContain("404");
594594 expect(html).toContain("Go home");
595595
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.
598598 const plain = await fetch(`${base}/ada/engine/frobnicate`);
599599 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 };
602605 expect(body.error).toBe("no_route");
603606 expect(body.message).toContain("/ada/engine/frobnicate");
604607

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs