commit d6aa38431bbd97845456cf2cfef17e192997eebc
parent 1fce53985d71a0e79aa59ed98afc5320a70e25c1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 12 Sep 2026 11:54:35 -0400
merge: one-core/phase-2-s2c — one shipsPwa, one _headers generator, one hub member type
S2c of Phase 2, reviewed clean: the Cloudflare _headers block is generated
once for site and hub from ordered path lists, byte-identical to the old
hand-written blocks, and the site block now grants CORS to /digests/* and
/duplicates.json (the wire change is its own commit, 3eeec47, with
Cloudflare's parser counting 12 → 14 rules). shipsPwa has one definition;
compose-hub's local HubSiteEntry is gone. Gates on the slice tip: tsc clean,
common 1083, mcp 205, scripts 71/1, both export builds compile, export e2e
172/172, hub 5/5.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 390 insertions(+), 96 deletions(-)
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -21,44 +21,9 @@ import {
buildHubCorpus,
renderHubLlmsTxt,
renderRobotsTxt,
+ type HubMemberInput,
} from "../lib/corpus";
-
-// CORS for the hub's own JSON (hub-sites.json). The hub is primarily a reader,
-// but keeping its endpoints CORS-open lets a hub-of-hubs federate it too. Same
-// rationale as compose-site.ts.
-const CORS_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
-/hub-sites.json
- Access-Control-Allow-Origin: *
-/site.json
- Access-Control-Allow-Origin: *
-/summaries/*
- Access-Control-Allow-Origin: *
-/subs/*
- Access-Control-Allow-Origin: *
-/transcripts/*
- Access-Control-Allow-Origin: *
-/stats/*
- Access-Control-Allow-Origin: *
-/corpus.json
- Access-Control-Allow-Origin: *
-/llms.txt
- Access-Control-Allow-Origin: *
-/robots.txt
- Access-Control-Allow-Origin: *
-`;
-
-// One entry the hub registry loads at boot to seed its trusted built-in pool.
-// The origin is derived from siteUrl on the client (coerceBuiltin), so a pool
-// site without a siteUrl can't be federated and is dropped.
-type HubSiteEntry = {
- siteId: string;
- siteTitle: string;
- siteUrl: string;
- accent?: string;
- hubUrl?: string;
- pwa: boolean;
- contract: number;
-};
+import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers";
async function exists(p: string): Promise<boolean> {
try {
@@ -73,8 +38,14 @@ async function main(): Promise<void> {
const paths = getPaths();
const publicDir = paths.exportPublicDir;
- // Built-in pool: every configured site that publishes a public URL.
- const builtins: HubSiteEntry[] = [];
+ // Built-in pool: every configured site that publishes a public URL. The entry
+ // the hub registry loads at boot to seed its trusted built-in pool, and the
+ // input buildHubCorpus maps — ONE type for both (lib/archive/contract.ts
+ // HubMemberInput), where this file used to restate it as a local
+ // `HubSiteEntry` with the same fields under a different name. The origin is
+ // derived from siteUrl on the client (coerceBuiltin), so a pool site without
+ // a siteUrl can't be federated and is dropped — by both consumers.
+ const builtins: HubMemberInput[] = [];
for (const site of listSites(paths)) {
if (!site.siteUrl) continue;
builtins.push({
@@ -114,7 +85,10 @@ async function main(): Promise<void> {
renderRobotsTxt({ siteUrl: hub.siteUrl }),
);
- await writeFile(path.join(publicDir, "_headers"), CORS_HEADERS);
+ await writeFile(
+ path.join(publicDir, "_headers"),
+ renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS),
+ );
// The hub always ships a PWA. Copy the hub service worker into place. Until
// the dedicated hub SW lands (Phase 7), fall back to the site SW so the PWA
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -32,6 +32,8 @@ import type { Manifest, SubsManifest } from "../lib/manifest";
import type { PostsManifest } from "../lib/posts";
import type { DigestsManifest } from "../lib/digests";
import { buildSiteDescriptor, type PublicSiteDescriptor } from "../lib/siteDescriptor";
+import { shipsPwa } from "../lib/archive/contract";
+import { renderHeadersFile } from "../lib/archive/headers";
import { effectiveSiteAliases } from "../lib/aliasesStore";
import {
buildSiteCorpus,
@@ -52,39 +54,6 @@ import {
type ArchiveManifestEntry,
} from "../lib/archiveOptions";
-// CORS + cache headers for Cloudflare Pages (a static `_headers` file at the
-// deploy root). All served data is public static JSON with no credentials, so
-// `Access-Control-Allow-Origin: *` is correct and lets a federating hub on any
-// origin read it. Default GETs with no custom headers are CORS-simple → no
-// preflight needed. `output: "export"` can't emit these via next.config, and
-// `serve`/`next dev` ignore this file (local/e2e CORS lives in serve.json).
-const CORS_HEADERS = `# Generated by compose-site.ts — do not edit by hand.
-/site.json
- Access-Control-Allow-Origin: *
-/search-aliases.json
- Access-Control-Allow-Origin: *
-/summaries/*
- Access-Control-Allow-Origin: *
-/subs/*
- Access-Control-Allow-Origin: *
-/transcripts/*
- Access-Control-Allow-Origin: *
-/posts/*
- Access-Control-Allow-Origin: *
-/stats/*
- Access-Control-Allow-Origin: *
-/archives/*
- Access-Control-Allow-Origin: *
-/corpus.json
- Access-Control-Allow-Origin: *
-/llms.txt
- Access-Control-Allow-Origin: *
-/robots.txt
- Access-Control-Allow-Origin: *
-/sitemap.xml
- Access-Control-Allow-Origin: *
-`;
-
// Emit the public federation contract: /site.json (branding + channels +
// freshness) and the CORS _headers file. Emitted for EVERY site regardless of
// whether it ships a PWA — a dumb instance is still federatable.
@@ -92,7 +61,10 @@ async function emitFederationFiles(
site: Site,
paths: ReturnType<typeof getPaths>,
): Promise<void> {
- await writeFile(path.join(paths.exportPublicDir, "_headers"), CORS_HEADERS);
+ await writeFile(
+ path.join(paths.exportPublicDir, "_headers"),
+ renderHeadersFile("compose-site.ts"),
+ );
const manifestPath = path.join(paths.exportSummariesDir, "manifest.json");
if (!(await exists(manifestPath))) return; // no composed data → no descriptor
@@ -201,13 +173,6 @@ async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> {
}
}
-// Whether this build ships an installable PWA. Resolved from the site's `pwa`
-// config flag (site builds are dumb instances by default) or forced on in hub
-// mode. Keep in sync with export/app/lib/mode.ts shipsPwa().
-function shipsPwa(site: Site): boolean {
- return site.pwa === true || process.env.INSTANCE_MODE === "hub";
-}
-
// Compose the service worker into the served public dir ONLY when this instance
// ships a PWA. The SW source lives outside public/ (export/service-worker/) so
// a dumb instance emits no /sw.js at all — not just an unregistered one. Mirrors
diff --git a/common/lib/archive/contract.ts b/common/lib/archive/contract.ts
@@ -143,10 +143,18 @@ export function pageUrl(
// installable) and opts in per site via the `pwa` config flag.
//
// Was two copies with "keep in sync" comments on each (compose-site.ts and
-// export/app/lib/mode.ts); this is the one. `process.env.INSTANCE_MODE` is read
-// here exactly as both copies read it — Next inlines that member expression
-// into the client bundle at build time, so a `typeof process` guard around it
-// would turn hub mode OFF in the browser rather than make it safer.
+// export/app/lib/mode.ts); S2c deleted both, and this is the one.
+//
+// `process.env.INSTANCE_MODE` is read bare, exactly as both copies read it, and
+// the reason that is safe is NOT that Next inlines it — it does not:
+// INSTANCE_MODE is neither `NEXT_PUBLIC_` nor listed in a next.config `env:`
+// block, so a client bundle would read `undefined` here. (An earlier draft of
+// this comment claimed the opposite; the S1 review checked.) It is safe because
+// no browser evaluates it: the only caller in the export app is
+// export/app/lib/mode.ts, whose only caller is export/app/layout.tsx, a SERVER
+// component, and the other caller is compose-site.ts, a build script. If this
+// ever becomes reachable from a client module it needs a
+// `typeof process !== "undefined"` guard AND an env var the client can see.
export function shipsPwa(site: { pwa?: boolean }): boolean {
return site.pwa === true || process.env.INSTANCE_MODE === "hub";
}
diff --git a/common/lib/archive/headers.test.ts b/common/lib/archive/headers.test.ts
@@ -0,0 +1,113 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+ HUB_CORS_PATHS,
+ SITE_CORS_PATHS,
+ contractCorsPaths,
+ renderHeadersFile,
+} from "./headers";
+
+// The whole generated site block, as a snapshot. `_headers` is a wire artifact:
+// a line lost here is a cross-origin reader that stops working on the CDN and
+// nowhere else (local `serve` blankets `**/*.json` with CORS). So the file is
+// asserted in full — an edit has to be deliberate enough to update this string.
+const SITE_HEADERS = `# Generated by compose-site.ts — do not edit by hand.
+/site.json
+ Access-Control-Allow-Origin: *
+/search-aliases.json
+ Access-Control-Allow-Origin: *
+/duplicates.json
+ Access-Control-Allow-Origin: *
+/summaries/*
+ Access-Control-Allow-Origin: *
+/subs/*
+ Access-Control-Allow-Origin: *
+/transcripts/*
+ Access-Control-Allow-Origin: *
+/posts/*
+ Access-Control-Allow-Origin: *
+/digests/*
+ Access-Control-Allow-Origin: *
+/stats/*
+ Access-Control-Allow-Origin: *
+/archives/*
+ Access-Control-Allow-Origin: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
+/sitemap.xml
+ Access-Control-Allow-Origin: *
+`;
+
+const HUB_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
+/hub-sites.json
+ Access-Control-Allow-Origin: *
+/site.json
+ Access-Control-Allow-Origin: *
+/summaries/*
+ Access-Control-Allow-Origin: *
+/subs/*
+ Access-Control-Allow-Origin: *
+/transcripts/*
+ Access-Control-Allow-Origin: *
+/stats/*
+ Access-Control-Allow-Origin: *
+/corpus.json
+ Access-Control-Allow-Origin: *
+/llms.txt
+ Access-Control-Allow-Origin: *
+/robots.txt
+ Access-Control-Allow-Origin: *
+`;
+
+test("renderHeadersFile: the site block, in full", () => {
+ assert.equal(renderHeadersFile("compose-site.ts"), SITE_HEADERS);
+ assert.equal(
+ renderHeadersFile("compose-site.ts", SITE_CORS_PATHS),
+ SITE_HEADERS,
+ );
+});
+
+test("renderHeadersFile: the hub block, in full", () => {
+ assert.equal(renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), HUB_HEADERS);
+});
+
+test("the two paths that used to be served without CORS are declared", () => {
+ // The wire change. A cross-origin viewer could read every other tree and got
+ // a CORS failure on exactly these two.
+ assert.ok(SITE_CORS_PATHS.includes("/digests/*"));
+ assert.ok(SITE_CORS_PATHS.includes("/duplicates.json"));
+});
+
+test("every contract layer and root file has a CORS line", () => {
+ for (const p of contractCorsPaths()) {
+ assert.ok(
+ SITE_CORS_PATHS.includes(p),
+ `${p} is on the wire but has no _headers entry`,
+ );
+ }
+});
+
+test("the hub surface is the site surface plus its own pool file", () => {
+ for (const p of HUB_CORS_PATHS) {
+ if (p === "/hub-sites.json") continue;
+ assert.ok(
+ SITE_CORS_PATHS.includes(p),
+ `${p} is declared by the hub but not by a site`,
+ );
+ }
+});
+
+test("every rendered entry is one path line and one indented header", () => {
+ const lines = renderHeadersFile("x.ts").split("\n").slice(0, -1);
+ assert.equal(lines[0], "# Generated by x.ts — do not edit by hand.");
+ const body = lines.slice(1);
+ assert.equal(body.length, SITE_CORS_PATHS.length * 2);
+ for (let i = 0; i < body.length; i += 2) {
+ assert.ok(body[i].startsWith("/"));
+ assert.equal(body[i + 1], " Access-Control-Allow-Origin: *");
+ }
+});
diff --git a/common/lib/archive/headers.ts b/common/lib/archive/headers.ts
@@ -0,0 +1,94 @@
+// 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",
+ "/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",
+];
+
+// 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.
+export function renderHeadersFile(
+ generator: string,
+ paths: readonly string[] = SITE_CORS_PATHS,
+): string {
+ const out = [`# Generated by ${generator} — do not edit by hand.`];
+ for (const p of paths) {
+ out.push(p, ` ${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}/*`),
+ ];
+}
diff --git a/export/app/lib/mode.ts b/export/app/lib/mode.ts
@@ -1,4 +1,5 @@
import { currentSite } from "./site";
+import { shipsPwa as contractShipsPwa } from "yt-dlp-transcript-common/lib/archive/contract";
// The export app renders one of two shells, selected at build time:
// site (default): a single-site archive, exactly as before.
@@ -13,17 +14,25 @@ export function instanceMode(): InstanceMode {
}
// Whether this build ships an installable PWA — the "dangerous permissions"
-// surface: a service worker, a web manifest, and installability. This is an
-// axis INDEPENDENT of the shell:
-// - hub mode always ships the PWA (Archilyzer IS the installable app);
-// - site mode is a dumb instance by default (federatable JSON only, not
-// installable) and opts in per site via the `pwa` config flag.
+// surface: a service worker, a web manifest, and installability. See
+// lib/archive/contract.ts shipsPwa() for what the axis means; this wrapper only
+// supplies the site, which the export app resolves at build/render time.
//
-// Resolved purely from config that is already available at build/render time
-// (INSTANCE_MODE env + the selected site's `pwa`), so the app shell and the
-// compose step agree without threading an extra env var. Keep this in sync with
-// compose-site.ts's shipsPwa().
+// Was a second copy of the predicate with a "keep in sync" comment pointing at
+// compose-site.ts's copy. Both are gone; there is one.
+//
+// Hub mode needs no branch of its own any more: currentSite() already returns
+// hubSite(), which sets `pwa: true` (export/app/lib/site.ts), and the contract
+// predicate reads INSTANCE_MODE itself — so the old
+// `if (instanceMode() === "hub") return true` short-circuit was true twice over.
+//
+// SERVER-ONLY, deliberately: the contract predicate reads
+// `process.env.INSTANCE_MODE`, which is not a `NEXT_PUBLIC_` var and is not in a
+// next.config `env:` block, so Next does NOT inline it into a client bundle.
+// The only caller is export/app/layout.tsx, a server component — and
+// currentSite() is server-only regardless, since it reads the sites dir.
+// Calling this from a client component would read `undefined` and silently turn
+// hub mode off; that would need a guard there, not an assumption here.
export function shipsPwa(): boolean {
- if (instanceMode() === "hub") return true;
- return currentSite().pwa === true;
+ return contractShipsPwa(currentSite());
}
diff --git a/plans/one-core-phase-2.md b/plans/one-core-phase-2.md
@@ -507,3 +507,134 @@ one TypeScript file touched.
4. **Test fixtures use `tmpdir()`, not the agent scratch dir.** A test that hard-coded a
job scratch path would not survive the job. `CUES_TEST_DIR` overrides the root, and
that is what the scratch-dir runs used.
+### S2c — shipped 2026-09-12
+
+Branch `one-core/phase-2-s2c`, off `7f86aef` (the
+`integrate/2026-09-storage-priority` tip, S1 merged). Four commits,
+`706a9ed` → `d1b3903`, plus this note, unmerged. **One deliberate wire
+change — two CORS lines, described below — and nothing else moved: no URL
+shape, no `corpus.json` byte, no CONTRACT version, no architecture
+allow-list entry, no new `components/*.ts`.**
+
+| commit | what |
+|---|---|
+| `706a9ed` | compose-hub's local `HubSiteEntry` deleted; it uses S1's `HubMemberInput` |
+| `674aeb4` | one `shipsPwa()` — compose-site.ts's and mode.ts's copies deleted |
+| `a168358` | `lib/archive/headers.ts`: one `_headers` generator, byte-identical output |
+| `d1b3903` | **wire change**: `/digests/*` and `/duplicates.json` get CORS |
+
+#### The `_headers` before/after
+
+The two additions are the whole diff, for the site. Composing the FACTS.md
+fixture recipe and diffing against
+`plans/tools/compose-fixture-one-youtube-channel/public/_headers`:
+
+```
+5a6,7
+> /duplicates.json
+> Access-Control-Allow-Origin: *
+12a15,16
+> /digests/*
+> Access-Control-Allow-Origin: *
+```
+
+Four added lines; every other line byte-identical, and the rest of the
+composed dir IDENTICAL modulo the build clock (16 files under `public/`,
+7 under `index/`). The **hub** block does not move at all: compose-hub at
+`7f86aef` and at the tip produce a byte-identical `_headers` and an
+identical `hub-sites.json`, with `corpus.json` differing only in
+`generatedAt`.
+
+Why the gap existed and why no test had caught it: `_headers` only exists on
+the CDN, and every local server in this repo is more permissive than
+Cloudflare. `serve` — what the export, hub and 2-origin suites all run
+behind — gives `**/*.json` a blanket `Access-Control-Allow-Origin: *`
+(`export/serve.json`). So a cross-origin viewer could read a federated
+site's transcripts, subs, posts, summaries, stats and archives, and got a
+CORS failure on its digests and its duplicates report, and nothing local
+could reproduce it.
+
+**`curl -I` cannot show this, and the reason is worth recording** rather
+than quoting a run that proves nothing. `wrangler pages dev` is the only
+local server here that reads `_headers` at all, and it adds
+`Access-Control-Allow-Origin: *` to every response of its own accord — a
+file named in no rule whatsoever still comes back with the header (checked
+with a `zzz-not-in-headers.json` dropped into the same composed dir). Before
+and after are indistinguishable over HTTP locally. What wrangler does report
+is its own parse of the file, on the composed fixture:
+
+```
+before: ✨ Parsed 12 valid header rules.
+after: ✨ Parsed 14 valid header rules.
+```
+
+Cloudflare's own parser, counting the two new rules as valid. The real
+before/after is a deploy.
+
+#### Divergences from the S2c brief
+
+**1. The generator is `lib/archive/headers.ts`, not `contract.ts`.** The
+brief allowed either. It is its own module because it carries two ordered
+path LISTS, not just a renderer: `_headers` is matched top-down, the file is
+diffed against a committed fixture, and the existing order is not the order
+`ROOT_FILES` + `CONTRACT.layers` would produce — so deriving the list would
+have reordered every line and buried the two-line wire change in noise. The
+contract still gets the last word: `contractCorsPaths()` + a test assert
+every `CONTRACT.layers` tree and every `ROOT_FILES` document has a line, so
+adding a layer and forgetting its CORS entry now fails a test rather than a
+deploy. No `node:*` import either way.
+
+**2. The hub does NOT get the two new lines.** The brief's wire change is
+"add `/digests/*` and `/duplicates.json` to the CORS set"; applied to the
+hub that would declare headers for paths a hub never serves. A hub holds no
+shard data — it reads every member cross-origin at runtime — so its block is
+unchanged, and the hub half of the fixture diff is empty. (`HUB_CORS_PATHS`
+does still list four shard trees the hub does not serve either; those are
+pre-existing and left alone, since removing them would be a second,
+unrelated change to the same file.)
+
+**3. `mode.ts`'s hub short-circuit is deleted, not kept.** The brief said to
+replace the copy with a call. The replacement made
+`if (instanceMode() === "hub") return true;` dead weight: `currentSite()`
+already returns `hubSite()`, which sets `pwa: true`, and the contract
+predicate reads `INSTANCE_MODE` itself. Same answer in both modes, so
+`shipsPwa()` is now one line.
+
+**4. `shipsPwa` stays server-called, and `contract.ts`'s comment about it is
+corrected.** No `typeof process` guard was added, per S1's divergence 3 — the
+callers are `compose-site.ts` (a build script) and `export/app/lib/mode.ts`,
+whose only caller is `export/app/layout.tsx`, a server component;
+`currentSite()` reads the sites dir, so `mode.ts` could not be client-side
+regardless. The comment S1 left on `shipsPwa` still asserted that Next
+inlines `process.env.INSTANCE_MODE` into the client bundle and that a guard
+would therefore break hub mode; the S1 review established that is false.
+That comment is rewritten to say why the bare read is actually safe and what
+a future client caller would owe. **`contract.ts`'s diff in this slice is
+comment-only** — verified by diffing with comment lines filtered out.
+
+#### Gates
+
+- `pnpm -r exec tsc --noEmit` — clean, exit 0, all six packages, after every
+ commit.
+- `pnpm --filter yt-dlp-transcript-common test` — **1083 passed / 0 failed**
+ (baseline 1077 at `7f86aef`, + 6 `headers.test.ts`; none lost). The
+ architecture test passes with its allow-list untouched.
+- `pnpm --filter yt-dlp-transcript-mcp test` — **205 passed / 0 failed**,
+ unchanged.
+- `pnpm test:scripts` — **71 passed / 1 skipped**, unchanged.
+- `pnpm --filter export exec next build` (site mode) — **compiled
+ successfully**, 11 static pages, 9 routes. The only warning is the
+ pre-existing NFT trace S1 documented.
+- `pnpm --filter export run build:hub` — **compiled successfully in 4.2s**,
+ TypeScript finished, then dies at exactly the known step:
+ `Error: useSearchSession must be used within a SearchSessionProvider`
+ prerendering `/ask`. Red on the base commit for a reason in
+ `common/components/SearchSessionContext.tsx`, which this slice does not
+ open; see S1's note. Compile and type-check are the part this slice needs,
+ and they pass.
+- **compose-site / compose-hub fixture diffs** — above.
+- **e2e**, behind the queue lock from the worktree (port block #12 —
+ editor 4201 / export-e2e 4220): export `e2e` **172 passed / 0 failed** (5.5 min),
+ `e2e:hub` **5 passed / 0 failed**. `e2e:2origin` was **not run**: it shells
+ `build:hub`, which is red on the base for the reason above, so it cannot
+ reach a spec — exactly as S1 recorded.