// The Cloudflare Pages `_headers` file, generated from the contract. // // A deployed archive is static JSON on a CDN, so the only place CORS can be // declared is a `_headers` file at the deploy root. `output: "export"` cannot // emit it from next.config, so the compose steps write it — and until now they // wrote it TWICE, as two hand-maintained string literals (compose-site.ts and // compose-hub.ts) that had already drifted apart. This module is the one // renderer; each compose step supplies the surface it actually serves. // // Browser-safe like the rest of `lib/archive/`: pure string building over // `CONTRACT` / `ROOT_FILES`, no `node:*`. import { CONTRACT, ROOT_FILES } from "./contract"; // The single header every served document gets. All served data is public // static JSON with no credentials, so `*` is correct and is what lets a // federating hub — or an MCP reader — on any origin read it. A default GET with // no custom headers is CORS-simple, so no preflight is involved. const CORS_HEADER = "Access-Control-Allow-Origin: *"; // Everything a SITE build serves, in the order the file has always listed it. // The order is preserved deliberately: `_headers` is matched top-down by // Cloudflare, the file is diffed against a committed fixture, and reordering it // would be a wire change with no benefit. // // `/digests/*` and `/duplicates.json` were the two gaps — both composed into // every site's public dir, neither declared here, so a cross-origin viewer got // a CORS failure on the digest shards and on the duplicates report while every // other tree read fine. Local `serve` hands `**/*.json` a blanket // `Access-Control-Allow-Origin: *` (export/serve.json), which is exactly why no // e2e run ever saw it. // // Kept honest by headers.test.ts: every `CONTRACT.layers` tree and every // `ROOT_FILES` document must appear here, so adding a layer to the contract and // forgetting its CORS line fails a test rather than a deploy. export const SITE_CORS_PATHS: readonly string[] = [ "/site.json", "/search-aliases.json", "/duplicates.json", "/tags.json", "/summaries/*", "/subs/*", "/transcripts/*", "/posts/*", "/digests/*", "/stats/*", "/archives/*", "/corpus.json", "/llms.txt", "/robots.txt", "/sitemap.xml", ]; // What a HUB build serves. The hub holds no shard data of its own — it reads // every member cross-origin at runtime — so this is a subset of the site // surface plus `/hub-sites.json`, the built-in trusted pool compose-hub writes. // The shard-tree entries it does list are vestigial but harmless: a header rule // for a path this origin never serves costs nothing, and a hub-of-hubs should // not have to care which of the two shapes it was pointed at. Nothing here // moves when the SITE list gains a tree — the hub serves no digest shards and // no duplicates report, so it declares neither. export const HUB_CORS_PATHS: readonly string[] = [ "/hub-sites.json", "/site.json", "/summaries/*", "/subs/*", "/transcripts/*", "/stats/*", "/corpus.json", "/llms.txt", "/robots.txt", ]; // What a WITHDRAWN path is served with (release 18): never stored at the edge. // Cloudflare Pages keeps serving a cached object after a deploy that changed or // removed it (the hub served a withdrawn posts shard from a 7-day edge cache), so // the tombstones that replace withdrawn content — and the manifests that list it // — are marked uncacheable. publish/tombstones.ts names the paths. const NO_STORE_HEADER = "Cache-Control: no-store"; // What a SITE serves with its own media type (a `_headers` rule for a path the // origin never serves costs nothing): a report's timeline feeds // (lib/report/feeds.ts), which Pages would otherwise serve as plain // `application/xml` and `application/json` — a feed reader and the page's // `` want them named. Readable cross-origin, as every // other document a site serves: a web feed reader fetches from its own origin. // Kept in step with lib/report/views.ts (REPORT_FEED_FILENAMES, REPORT_FEED_MIME) // by headers.test.ts. export const SITE_TYPED_PATHS: readonly { path: string; type: string }[] = [ { path: "/reports/:report/feed.xml", type: "application/rss+xml; charset=utf-8" }, { path: "/reports/:report/feed.json", type: "application/feed+json; charset=utf-8" }, ]; // Whether a `_headers` path rule (a literal path, or one ending in a `*` splat) // matches `p`, itself a literal path or a splat rule. A splat rule covers every // path under its prefix. function ruleCovers(rule: string, p: string): boolean { if (rule.endsWith("*")) return p.startsWith(rule.slice(0, -1)); return rule === p; } // Render a `_headers` file: a banner naming the generator, then one path / // indented-header pair per served surface. `generator` is the script name so a // reader of a deploy artifact knows what to edit instead of the file. // // `types` adds, after the CORS lines, one rule per path served with its own // `Content-Type` (and CORS, where no rule above covers it — see below). // // `noStore` adds, after the CORS lines and the types, one rule per path marked // `Cache-Control: no-store`. It carries the CORS header too — but only where no // CORS rule above already covers the path: every matching rule applies, and a // header a later rule sets again is APPENDED (wrangler's attachHeaders), so a // second CORS line would serve `Access-Control-Allow-Origin: *, *`, which no // browser accepts. A site's `/posts/*` CORS rule covers its posts tombstones; the // hub, which lists no posts tree, gets its CORS from the no-store rule itself. export function renderHeadersFile( generator: string, paths: readonly string[] = SITE_CORS_PATHS, opts: { noStore?: readonly string[]; types?: readonly { path: string; type: string }[] } = {}, ): string { const out = [`# Generated by ${generator} — do not edit by hand.`]; for (const p of paths) { out.push(p, ` ${CORS_HEADER}`); } const types = opts.types ?? []; if (types.length > 0) { out.push("# Served with their own media type."); for (const t of types) { out.push(t.path, ` Content-Type: ${t.type}`); if (!paths.some((rule) => ruleCovers(rule, t.path))) out.push(` ${CORS_HEADER}`); } } const noStore = opts.noStore ?? []; if (noStore.length > 0) { out.push("# Withdrawn content (tombstones): never stored at the edge."); for (const p of noStore) { out.push(p, ` ${NO_STORE_HEADER}`); if (!paths.some((rule) => ruleCovers(rule, p))) out.push(` ${CORS_HEADER}`); } } return `${out.join("\n")}\n`; } // The contract surfaces a site must declare, for the drift test. Exported so // the assertion lives with the data rather than being re-derived in the test. export function contractCorsPaths(): string[] { return [ ...ROOT_FILES.map((f) => `/${f}`), ...CONTRACT.layers.map((l) => `/${l}/*`), ]; }