Public: anyone can read this; the crew can change it.

Website spec checks 1-2: webspec() reports head and header findings for a URL, and `bun scripts/smoke.ts <url> --standards` prints them #2956

Merged opened this

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

Merged

scout/forge:scout/2953-website-spec-checks-1-2-webspec-reports → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/2953-website-spec-checks-1-2-webspec-reports was merged into main as 356edf8.

Review

Sign in to review this change.
4 files changed +326 −1
README.md +7
@@ -314,6 +314,13 @@ never read as a pass. Spend and run bounds are proven in process only; this
314314 pull adds the proof but does not reopen signup — the gitops gate stays for a
315315 person.
316316
317 `bun scripts/smoke.ts <url> --standards` prints the website-specification
318 report (`src/release/webspec.ts`): it fetches each path in forge.json's
319 `app.looks` from `<url>` and prints one line per head or header finding with
320 the specification.website section it cites. It only reports and always exits
321 0. `(edge)` marks a finding the edge in front of the app may own (HSTS), so
322 fix it there rather than in the app. Each fetch gives up after 10 seconds.
323
317324 What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your
318325 thoughts, then the issues you can write to that are not shipped, oldest first
319326 (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read
scripts/smoke.ts +16 −1
@@ -10,6 +10,10 @@
1010 * seen. `/sw.js` must be a 200 JavaScript response whose navigation branch
1111 * never answers with `Response.error()`.
1212 *
13 * `--standards` runs the website-specification report (`release/webspec.ts`)
14 * over forge.json's `app.looks` paths, prints one line per finding with its
15 * section, and exits 0: it reports, it does not gate.
16 *
1317 * `--outsider` also signs up a fresh account and proves each thing a
1418 * stranger must not do is refused over HTTP and MCP (`release/outsider.ts`,
1519 * #663); the terminal door is proven by the gate. It makes one real account, so run it against a forge whose signup is open to it, with
@@ -17,6 +21,7 @@
1721 */
1822
1923 import { smoke } from "../src/release/smoke.ts";
24 import { webspec } from "../src/release/webspec.ts";
2025 import { outsider } from "../src/release/outsider.ts";
2126 export * from "../src/release/smoke.ts";
2227
@@ -29,18 +34,28 @@ if (import.meta.main) {
2934 };
3035 const withOutsider = args.includes("--outsider");
3136 if (withOutsider) args.splice(args.indexOf("--outsider"), 1);
37 const standards = args.includes("--standards");
38 if (standards) args.splice(args.indexOf("--standards"), 1);
3239 const version = flag("--version");
3340 const wait = flag("--wait");
3441 const base = args[0];
3542 const seconds = wait === undefined ? 120 : Number(wait);
3643 if (!base || !Number.isFinite(seconds) || seconds < 0 || args.length > 1) {
37 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider]");
44 console.error("usage: bun scripts/smoke.ts <url> [--version <v>] [--wait <seconds>] [--outsider] [--standards]");
3845 process.exit(2);
3946 }
4047 if (withOutsider && !process.env["FORGE_TOKEN"]) {
4148 console.error("--outsider needs FORGE_TOKEN naming the owner's repos: private reads, steering, the form door and MCP tells cannot be tried without it");
4249 process.exit(2);
4350 }
51 if (standards) {
52 const looks = (await Bun.file(new URL("../forge.json", import.meta.url)).json())?.app?.looks;
53 const paths: string[] = Array.isArray(looks) ? looks.filter((p): p is string => typeof p === "string") : ["/"];
54 const findings = await webspec(base, paths);
55 for (const f of findings) console.log(`${f.path} ${f.check}${f.edge ? " (edge)" : ""}: ${f.message} <${f.section}>`);
56 console.log(`standards: ${findings.length} finding(s) over ${paths.length} path(s); report only`);
57 process.exit(0);
58 }
4459 const failures = await smoke(base, process.env["FORGE_TOKEN"] ?? "", { version, waitMs: seconds * 1000 });
4560 for (const f of failures)
4661 console.error(`FAIL ${f.url} agent=${f.agent} status=${f.status} content-type=${f.contentType || "-"} (${f.why})`);
src/release/webspec.ts added +158
@@ -0,0 +1,158 @@
1 /**
2 * The website specification as a report (#2915, checks 1 and 2). `webspec()`
3 * fetches each path of a site once and returns what the page's head and
4 * response headers get wrong, each finding citing its specification.website
5 * section. It reports; it never throws on a bad page and never fails a run.
6 * The HTML is read with small regexes, one function per check.
7 */
8
9 export interface Finding {
10 path: string;
11 check: string;
12 message: string;
13 /** The specification.website section this finding cites. */
14 section: string;
15 /** Set when the CDN or edge in front of the app may own the header. */
16 edge?: true;
17 }

Anchors are guesses (scout said so). Check them against the real page before anyone relies on the output.

18
19 type Fetcher = (input: string, init?: RequestInit) => Promise<Response>;
20 type Problem = { check: string; message: string; section: string; edge?: true };
21
22 const TIMEOUT_MS = 10000;
23 const SPEC = "https://specification.website/";
24 const s = (name: string) => `${SPEC}#${name}`;
25
26 const tags = (html: string, name: string): string[] => html.match(new RegExp(`<${name}\\b[^>]*>`, "gi")) ?? [];
27
28 function attr(tag: string, name: string): string | undefined {
29 const m = new RegExp(`(?:^|[\\s"'/])${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i").exec(tag);
30 return m ? (m[1] ?? m[2] ?? m[3] ?? "") : undefined;
31 }
32
33 const metas = (html: string) => tags(html, "meta");
34 const metaBy = (html: string, key: "name" | "property", value: string) =>
35 metas(html).filter((t) => attr(t, key)?.toLowerCase() === value);
36 const metaContent = (html: string, key: "name" | "property", value: string) =>
37 metaBy(html, key, value)[0] && (attr(metaBy(html, key, value)[0]!, "content") ?? "");
38 const links = (html: string, rel: string) =>
39 tags(html, "link").filter((t) => (attr(t, "rel") ?? "").toLowerCase().split(/\s+/).includes(rel));
40
41 export function headProblems(html: string): Problem[] {
42 const out: Problem[] = [];
43 const add = (check: string, message: string, section: string) => out.push({ check, message, section: s(section) });
44 if (!/^\s*<!doctype html>/i.test(html)) add("doctype", "the page does not start with <!doctype html>", "doctype");
45 const htmlTag = tags(html, "html")[0] ?? "";
46 if (!(attr(htmlTag, "lang") ?? "").trim()) add("lang", "<html> has no lang attribute", "lang");
47 const charset = metas(html).some(
48 (t) => /^utf-8$/i.test(attr(t, "charset") ?? "") || (/content-type/i.test(attr(t, "http-equiv") ?? "") && /utf-8/i.test(attr(t, "content") ?? "")),
49 );
50 if (!charset) add("charset", 'no <meta charset="utf-8">', "charset");
51 const viewport = metaContent(html, "name", "viewport");
52 if (viewport === undefined) add("viewport", "no viewport meta", "viewport");
53 else if (/maximum-scale\s*=|user-scalable\s*=\s*(no|0)/i.test(viewport))
54 add("viewport", "the viewport locks zoom (maximum-scale or user-scalable=no)", "viewport");
55 const titles = html.match(/<title\b[^>]*>/gi)?.length ?? 0;
56 if (titles !== 1) add("title", `expected exactly one <title>, found ${titles}`, "title");
57 if (!(metaContent(html, "name", "description") ?? "").trim()) add("description", "no meta description", "description");
58 const canonical = links(html, "canonical").map((t) => attr(t, "href") ?? "")[0];
59 const ogUrl = metaContent(html, "property", "og:url");
outdated

metaContent returns undefined or a string, never null; drop || viewport === null.

60 if (!canonical) add("canonical", 'no <link rel="canonical">', "canonical");
61 else if (canonical !== ogUrl) add("canonical", `canonical (${canonical}) is not og:url (${ogUrl ?? "missing"})`, "canonical");
62 const themes = metaBy(html, "name", "theme-color").map((t) => attr(t, "media") ?? "");
63 const has = (scheme: string) => themes.some((m) => m.replace(/\s+/g, "").includes(`prefers-color-scheme:${scheme}`));
64 if (!has("light") || !has("dark")) add("theme-color", "theme-color needs a light and a dark variant (media=prefers-color-scheme)", "theme-color");
65 if (!(metaContent(html, "name", "color-scheme") ?? "").trim()) add("color-scheme", "no color-scheme meta", "color-scheme");
66 for (const p of ["og:title", "og:type", "og:url", "og:description", "og:image"])
67 if (!(metaContent(html, "property", p) ?? "").trim()) add("open-graph", `missing ${p}`, "open-graph");
68 return out;
69 }
70
71 /** Targets the head points at that must resolve: icons and the manifest. */
72 export function headTargets(html: string, pageUrl: string): { urls: string[]; bad: string[] } {
73 const hrefs = [...links(html, "icon"), ...links(html, "apple-touch-icon"), ...links(html, "manifest")].map((t) => attr(t, "href") ?? "");
74 const urls: string[] = [];
75 const bad: string[] = [];
76 for (const h of hrefs) {
77 try {
78 const u = new URL(h, pageUrl).href;
79 if (!urls.includes(u)) urls.push(u);
80 } catch {
81 bad.push(h);
82 }
83 }
outdated

Empty catch {} swallows an invalid href silently; at least report it as a head-links finding.

84 return { urls, bad };
85 }
86
87 export function headerProblems(h: Headers, https: boolean): Problem[] {
88 const out: Problem[] = [];
89 const add = (check: string, message: string, section: string, edge?: true) =>
90 out.push({ check, message, section: s(section), ...(edge ? { edge } : {}) });
91 const csp = h.get("content-security-policy");
92 if (!csp) add("csp", "no Content-Security-Policy", "csp");
93 else {
94 if (/'unsafe-eval'/i.test(csp)) add("csp", "CSP allows 'unsafe-eval'", "csp");
95 const directives = csp.split(";").map((d) => d.trim());
96 const script = directives.find((d) => /^script-src(-elem)?\s/i.test(d)) ?? directives.find((d) => /^default-src\s/i.test(d));
97 if (script && /'unsafe-inline'/i.test(script)) add("csp", "CSP allows 'unsafe-inline' scripts", "csp");
98 if (!directives.some((d) => /^frame-ancestors\s/i.test(d))) add("frame-ancestors", "CSP has no frame-ancestors", "frame-ancestors");
99 }
100 if ((h.get("x-content-type-options") ?? "").toLowerCase() !== "nosniff") add("nosniff", "X-Content-Type-Options is not nosniff", "nosniff");
101 if (!h.get("referrer-policy")) add("referrer-policy", "no Referrer-Policy", "referrer-policy");
102 if (!h.get("permissions-policy")) add("permissions-policy", "no Permissions-Policy", "permissions-policy");
103 if (https && !h.get("strict-transport-security")) add("hsts", "no Strict-Transport-Security on an https URL", "hsts", true);
104 if (h.has("x-xss-protection")) add("x-xss-protection", "X-XSS-Protection is set; drop it", "x-xss-protection");
105 if (/\bimmutable\b/i.test(h.get("cache-control") ?? "")) add("cache-html", "HTML is cached immutable; only hashed assets may be", "caching");
106 return out;
107 }
108
109 /** Fetches `paths` of `base` and reports head and header findings. */
110 export async function webspec(base: string, paths: string[], fetcher: Fetcher = fetch): Promise<Finding[]> {
111 const findings: Finding[] = [];
112 for (const path of paths) {
113 const report = (ps: Problem[]) => {
114 for (const p of ps) findings.push({ path, ...p });
115 };
116 let url: string;
117 try {
118 url = new URL(path, base).href;
119 } catch {
120 report([{ check: "fetch", message: `${path} is not a URL`, section: SPEC }]);
121 continue;

headProblems and headTargets are exported but used only inside this file and webspec(). Unexport them unless a test needs them.

122 }
123 let res: Response;
124 let html: string;
125 try {
126 res = await fetcher(url, { headers: { accept: "text/html" }, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) });
outdated

No timeout: a hung server stalls the whole report. Add signal: AbortSignal.timeout(10000) here and on the icon and ETag fetches.

127 html = await res.text();
128 } catch (e) {
129 report([{ check: "fetch", message: `could not fetch ${url}: ${e instanceof Error ? e.message : String(e)}`, section: SPEC }]);
130 continue;
131 }
132 if (!res.ok) {
133 report([{ check: "fetch", message: `${url} answered ${res.status}`, section: SPEC }]);
134 continue;
135 }
136 report(headProblems(html));
137 const { urls, bad } = headTargets(html, url);
138 for (const h of bad) report([{ check: "head-links", message: `"${h}" is not a URL`, section: s("icons") }]);
139 for (const target of urls) {
140 const status = await fetcher(target, { signal: AbortSignal.timeout(TIMEOUT_MS) }).then(
141 async (r) => (await r.body?.cancel(), r.status),
142 () => 0,
143 );
144 if (status < 200 || status >= 300) report([{ check: "head-links", message: `${target} answered ${status || "nothing"}`, section: s("icons") }]);
145 }
146 report(headerProblems(res.headers, new URL(url).protocol === "https:"));
147 const etag = res.headers.get("etag");
148 if (!etag) report([{ check: "etag", message: "no ETag on the HTML", section: s("caching") }]);
149 else {
150 const again = await fetcher(url, { headers: { accept: "text/html", "if-none-match": etag }, redirect: "manual", signal: AbortSignal.timeout(TIMEOUT_MS) }).then(
151 async (r) => (await r.body?.cancel(), r.status),
152 () => 0,
153 );
154 if (again !== 304) report([{ check: "etag", message: `If-None-Match answered ${again || "nothing"}, not 304`, section: s("caching") }]);
155 }
156 }
157 return findings;
158 }
test/webspec.test.ts added +145
@@ -0,0 +1,145 @@
1 import { expect, test } from "bun:test";
2 import { webspec } from "../src/release/webspec.ts";
3
4 const GOOD_HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1"><title>Home</title>
6 <meta name="description" content="d"><link rel="canonical" href="https://x.test/">
7 <meta name="theme-color" content="#fff" media="(prefers-color-scheme: light)">
8 <meta name="theme-color" content="#000" media="(prefers-color-scheme: dark)">
9 <meta name="color-scheme" content="light dark">
10 <meta property="og:title" content="t"><meta property="og:type" content="website"><meta property="og:url" content="https://x.test/">
11 <meta property="og:description" content="d"><meta property="og:image" content="https://x.test/og.png">
12 <link rel="icon" href="/icon.svg"><link rel="manifest" href="/m.json"></head><body>hi</body></html>`;
13
14 const GOOD_HEADERS: Record<string, string> = {
15 "content-type": "text/html",
16 "content-security-policy": "default-src 'self'; script-src 'self'; frame-ancestors 'none'",
17 "x-content-type-options": "nosniff",
18 "referrer-policy": "same-origin",
19 "permissions-policy": "camera=()",
20 "strict-transport-security": "max-age=63072000",
21 "cache-control": "no-cache",
22 etag: '"v1"',
23 };
24
25 function site(html = GOOD_HTML, headers: Record<string, string> = GOOD_HEADERS, extra: Record<string, number> = {}) {
26 return async (input: string, init?: RequestInit) => {
27 const u = new URL(input);
28 if (u.pathname in extra) return new Response("x", { status: extra[u.pathname]! });
29 if (u.pathname === "/icon.svg" || u.pathname === "/m.json") return new Response("x");
30 const h = new Headers(init?.headers);
31 if (h.get("if-none-match") && headers["etag"] === h.get("if-none-match")) return new Response(null, { status: 304 });
32 return new Response(html, { headers });
33 };
34 }
35
36 const checks = async (html?: string, headers?: Record<string, string>, extra?: Record<string, number>) =>
37 (await webspec("https://x.test", ["/"], site(html, headers, extra))).map((f) => f.check);
38 const without = (k: string) => Object.fromEntries(Object.entries(GOOD_HEADERS).filter(([n]) => n !== k));
39
40 test("a page meeting every check has no findings", async () => {
41 expect(await webspec("https://x.test", ["/"], site())).toEqual([]);
42 });
43
44 test("each head check yields exactly its finding", async () => {
45 const cases: [string, string, string][] = [
46 ["doctype", GOOD_HTML.replace("<!doctype html>", ""), "doctype"],
47 ["lang", GOOD_HTML.replace(' lang="en"', ""), "lang"],
48 ["charset", GOOD_HTML.replace('<meta charset="utf-8">', ""), "charset"],
49 ["zoom", GOOD_HTML.replace("initial-scale=1", "maximum-scale=1"), "viewport"],
50 ["user-scalable", GOOD_HTML.replace("initial-scale=1", "user-scalable=no"), "viewport"],
51 ["two titles", GOOD_HTML.replace("</head>", "<title>b</title></head>"), "title"],
52 ["no title", GOOD_HTML.replace("<title>Home</title>", ""), "title"],
53 ["description", GOOD_HTML.replace('<meta name="description" content="d">', ""), "description"],
54 ["canonical missing", GOOD_HTML.replace(/<link rel="canonical"[^>]*>/, ""), "canonical"],
55 ["canonical differs", GOOD_HTML.replace('canonical" href="https://x.test/"', 'canonical" href="https://x.test/a"'), "canonical"],
56 ["dark theme", GOOD_HTML.replace(/<meta name="theme-color" content="#000"[^>]*>/, ""), "theme-color"],
57 ["color-scheme", GOOD_HTML.replace('<meta name="color-scheme" content="light dark">', ""), "color-scheme"],
58 ["og:image", GOOD_HTML.replace(/<meta property="og:image"[^>]*>/, ""), "open-graph"],
59 ];
60 for (const [name, html, check] of cases) expect([name, await checks(html)]).toEqual([name, [check]]);
61 });
62
63 test("icons and the manifest must resolve", async () => {
64 expect(await checks(undefined, undefined, { "/icon.svg": 404 })).toEqual(["head-links"]);
65 expect(await checks(undefined, undefined, { "/m.json": 500 })).toEqual(["head-links"]);
66 });
67
68 test("each header check yields exactly its finding", async () => {
69 const h = (k: string, v: string) => ({ ...GOOD_HEADERS, [k]: v });
70 const cases: [string, Record<string, string>, string][] = [
71 ["csp missing", without("content-security-policy"), "csp"],
72 ["unsafe-eval", h("content-security-policy", "script-src 'self' 'unsafe-eval'; frame-ancestors 'none'"), "csp"],
73 ["unsafe-inline script", h("content-security-policy", "script-src 'self' 'unsafe-inline'; frame-ancestors 'none'"), "csp"],
74 ["frame-ancestors", h("content-security-policy", "script-src 'self'"), "frame-ancestors"],
75 ["nosniff", without("x-content-type-options"), "nosniff"],
76 ["referrer", without("referrer-policy"), "referrer-policy"],
77 ["permissions", without("permissions-policy"), "permissions-policy"],
78 ["xss", h("x-xss-protection", "1"), "x-xss-protection"],
79 ["immutable html", h("cache-control", "public, max-age=1, immutable"), "cache-html"],
80 ["etag", without("etag"), "etag"],
81 ];
82 for (const [name, headers, check] of cases) expect([name, await checks(undefined, headers)]).toEqual([name, [check]]);
83 });
84
85 test("style-src unsafe-inline is not a script finding", async () => {
86 const csp = "script-src 'self'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'";
87 expect(await checks(undefined, { ...GOOD_HEADERS, "content-security-policy": csp })).toEqual([]);
88 });
89
90 test("HSTS is an edge finding on https only", async () => {
91 const found = await webspec("https://x.test", ["/"], site(GOOD_HTML, without("strict-transport-security")));
92 expect(found.map((f) => [f.check, f.edge])).toEqual([["hsts", true]]);
93 expect(await webspec("http://x.test", ["/"], site(GOOD_HTML, without("strict-transport-security")))).toEqual([]);
94 });
95
96 test("an ETag that does not answer 304 is a finding", async () => {
97 const f = async (input: string) =>
98 input.endsWith(".svg") || input.endsWith(".json") ? new Response("x") : new Response(GOOD_HTML, { headers: GOOD_HEADERS });
99 const found = await webspec("https://x.test", ["/"], f);
100 expect(found.map((x) => x.check)).toEqual(["etag"]);
101 });
102
103 test("findings are plain data citing a section; failures to fetch are findings", async () => {
104 const [f] = await webspec("https://x.test", ["/a"], site(GOOD_HTML.replace(' lang="en"', "")));
105 expect(f).toEqual({ path: "/a", check: "lang", message: expect.any(String), section: "https://specification.website/#lang" });
106 const down = await webspec("https://x.test", ["/"], async () => {
107 throw new Error("boom");
108 });
109 expect(down.map((x) => x.check)).toEqual(["fetch"]);
110 });
111
112 test("a page that does not answer 2xx is a fetch finding", async () => {
113 const f = async () => new Response("nope", { status: 500 });
114 const found = await webspec("https://x.test", ["/"], f);
115 expect(found.map((x) => x.check)).toEqual(["fetch"]);
116 });
117
118 test("default-src 'unsafe-inline' counts when there is no script-src", async () => {
119 const csp = "default-src 'self' 'unsafe-inline'; frame-ancestors 'none'";
120 expect(await checks(undefined, { ...GOOD_HEADERS, "content-security-policy": csp })).toEqual(["csp"]);
121 });
122
123 test("the http-equiv charset form is accepted", async () => {
124 const html = GOOD_HTML.replace('<meta charset="utf-8">', '<meta http-equiv="Content-Type" content="text/html; charset=utf-8">');
125 expect(await checks(html)).toEqual([]);
126 });
127
128 test("apple-touch-icon must resolve, and an invalid href is a finding", async () => {
129 const html = GOOD_HTML.replace("</head>", '<link rel="apple-touch-icon" href="/touch.png"></head>');
130 expect(await checks(html, undefined, { "/touch.png": 404 })).toEqual(["head-links"]);
131 expect(await checks(html, undefined, { "/touch.png": 200 })).toEqual([]);
132 const bad = GOOD_HTML.replace("</head>", '<link rel="icon" href="http://[bad"></head>');
133 expect(await checks(bad)).toEqual(["head-links"]);
134 });
135
136 test("every fetch carries a timeout signal", async () => {
137 const seen: boolean[] = [];
138 const inner = site();
139 await webspec("https://x.test", ["/"], async (u, init) => {
140 seen.push(init?.signal instanceof AbortSignal);
141 return inner(u, init);
142 });
143 expect(seen.length).toBeGreaterThan(3);
144 expect(seen.every(Boolean)).toBe(true);
145 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs