commit 11880bf4ad5f484ce4e65544b277b5e05652a5f1
parent 3a349be6fcdc1b9c45146943cf75601a3af33814
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 21:05:27 -0400
common: the hub carries no site's data — compose-hub clears every per-site entry from public/ and ships the global aliases; builtHubProblem refuses a hub bundle that still carries a site's data trees (review HIGH 1)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 114 insertions(+), 0 deletions(-)
diff --git a/common/bin/compose-hub.test.ts b/common/bin/compose-hub.test.ts
@@ -16,6 +16,7 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { getPaths, type Paths } from "../lib/paths";
import { main } from "./compose-hub";
+import { readGlobalAliases } from "../lib/aliasesStore";
// Run with:
// pnpm --filter yt-dlp-transcript-common test
@@ -170,3 +171,54 @@ test("an unlisted site is in none of the hub's files; a listed one is in each",
rmSync(root, { recursive: true, force: true });
}
});
+
+// Release 17 slice XP (the review's HIGH 1): a site's compose leaves its data
+// in public/ — a private site's X posts included — and the hub builds from
+// public/ next. compose-hub removes every per-site entry, through a link only
+// the link, and ships the global alias dictionary as its own.
+test("compose-hub removes a site's data from public/, a linked entry by its link only", async () => {
+ const root = mkdtempSync(path.join(tmpdir(), "compose-hub-"));
+ const log = console.log;
+ try {
+ const paths = fixturePaths(root);
+ const pub = paths.exportPublicDir;
+ // What a private site's compose leaves.
+ for (const tree of ["summaries", "transcripts", "subs", "digests", "stats", "archives"]) {
+ mkdirSync(path.join(pub, tree, "x"), { recursive: true });
+ writeFileSync(path.join(pub, tree, "x", "page-0000.json"), "[]");
+ }
+ for (const f of ["site.json", "tags.json", "duplicates.json", "chart-templates.json", "sitemap.xml"]) {
+ writeFileSync(path.join(pub, f), "{}");
+ }
+ writeFileSync(path.join(pub, "search-aliases.json"), JSON.stringify({ aliases: [{ site: 1 }] }));
+ // posts/ as a worktree has it: a link into the primary checkout.
+ const primaryPosts = path.join(root, "primary-public", "posts");
+ mkdirSync(path.join(primaryPosts, "jer-x"), { recursive: true });
+ writeFileSync(path.join(primaryPosts, "manifest.json"), '{"channels":[{"slug":"jer-x"}]}');
+ symlinkSync(primaryPosts, path.join(pub, "posts"));
+ // A checked-in static asset stays.
+ writeFileSync(path.join(pub, "globe.svg"), "<svg/>");
+ // No global dictionary file: the seeded defaults are the global one.
+ const hubPaths = { ...paths, globalAliasesFile: path.join(root, "no-aliases.json") };
+
+ console.log = () => {};
+ await main({ paths: hubPaths });
+ console.log = log;
+
+ for (const gone of [
+ "summaries", "transcripts", "subs", "posts", "digests", "stats", "archives",
+ "site.json", "tags.json", "duplicates.json", "chart-templates.json", "sitemap.xml",
+ ]) {
+ assert.ok(!existsSync(path.join(pub, gone)), `${gone} was removed`);
+ }
+ assert.ok(existsSync(path.join(primaryPosts, "manifest.json")), "the link's target is untouched");
+ assert.ok(existsSync(path.join(pub, "globe.svg")));
+ assert.ok(existsSync(path.join(pub, "hub-sites.json")));
+ const aliases = JSON.parse(readFileSync(path.join(pub, "search-aliases.json"), "utf8"));
+ assert.deepEqual(aliases, { aliases: readGlobalAliases(hubPaths).aliases });
+ assert.ok(!JSON.stringify(aliases).includes('"site"'), "not the site's aliases");
+ } finally {
+ console.log = log;
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -11,6 +11,10 @@
// there is no index to walk
// public/_headers <- CORS for the hub's own served JSON
// public/sw.js <- the hub service worker (the hub always ships a PWA)
+// public/search-aliases.json <- the global alias dictionary
+//
+// and REMOVES every per-site entry a site's compose left in public/
+// (SITE_ONLY_PUBLIC_ENTRIES below): the hub holds no site's data.
//
// The hub's branding ("Archilyzer") is resolved at build/render time from the
// HomepageConfig (see export/app/lib/site.ts hubSite()), not composed here.
@@ -32,6 +36,7 @@ import {
import { HUB_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers";
import { buildPoolSummary } from "../controller/poolSummary";
import { HUB_SUMMARY_FILE, toHubSummary } from "../lib/hubSummary";
+import { readGlobalAliases } from "../lib/aliasesStore";
import { runIfEntryPoint } from "./_cli";
import { writePublicFile } from "./_publicFile";
@@ -76,10 +81,45 @@ async function composeHubSummary(
}
}
+// THE HUB CARRIES NO SITE'S DATA (release 17 slice XP, the review's HIGH 1).
+// public/ is the one directory every site composes into in turn, and the hub
+// builds from it next: whatever the last site's compose left there — its data
+// trees and its per-site files — `next build` copied into the hub's out/, and
+// the hub deploy shipped it. The live hub served jeralyzer's posts manifest;
+// after a PRIVATE site's build it would have served every X post. So the hub's
+// compose removes every per-site entry first. A worktree's public/ entries are
+// links into the primary checkout: rm removes the link, never its target.
+export const SITE_ONLY_PUBLIC_ENTRIES: readonly string[] = [
+ "summaries",
+ "transcripts",
+ "subs",
+ "posts",
+ "digests",
+ "stats",
+ "archives",
+ "site.json",
+ "tags.json",
+ "duplicates.json",
+ "search-aliases.json",
+ "chart-templates.json",
+ "sitemap.xml",
+];
+
export async function main(opts: { paths?: Paths } = {}): Promise<void> {
const paths = opts.paths ?? getPaths();
const publicDir = paths.exportPublicDir;
+ for (const entry of SITE_ONLY_PUBLIC_ENTRIES) {
+ await rm(path.join(publicDir, entry), { recursive: true, force: true });
+ }
+ // The hub's own alias dictionary is the global one (no site's overrides):
+ // what a hub reader loads for hub-wide search (lib/archive/reader-hub.ts),
+ // where it used to get whichever site had composed last.
+ await writePublicFile(
+ path.join(publicDir, "search-aliases.json"),
+ JSON.stringify({ aliases: readGlobalAliases(paths).aliases }),
+ );
+
// Built-in pool: every configured site that publishes a public URL and is
// listed. An unlisted site (`listed: false`) still builds and deploys, but the
// hub does not list it: not a member, not in federated search, not in the
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -116,6 +116,14 @@ test("builtHubProblem accepts only a hub bundle", () => {
builtHubProblem(none.dir),
"export/out holds no hub build — build the hub first",
);
+
+ // Release 17 XP: a hub bundle composed over a site's data is refused.
+ mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true });
+ mkdirSync(path.join(hub.dir, "summaries"));
+ assert.equal(
+ builtHubProblem(hub.dir),
+ "export/out holds a hub build that still carries a site's data (summaries, posts) — build the hub again",
+ );
} finally {
hub.cleanup();
site.cleanup();
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -185,9 +185,23 @@ export function builtHubProblem(outDir: string): string | null {
if (!existsSync(path.join(outDir, "hub-sites.json"))) {
return "export/out holds no hub build — build the hub first";
}
+ // The hub holds no site's data (compose-hub removes it): a hub bundle that
+ // still carries a site's data trees was composed over one, and could ship
+ // that site's posts — a private site's included.
+ const carried = HUB_FORBIDDEN_TREES.filter((tree) => existsSync(path.join(outDir, tree)));
+ if (carried.length > 0) {
+ return (
+ `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` +
+ `build the hub again`
+ );
+ }
return null;
}
+// The per-site data trees a hub bundle must never carry (the trees of
+// compose-hub's SITE_ONLY_PUBLIC_ENTRIES).
+const HUB_FORBIDDEN_TREES = ["summaries", "transcripts", "subs", "posts", "digests", "stats", "archives"];
+
/**
* Why `outDir` — the homepage package's `homepage/out` — may not be deployed as
* the homepage, as one sentence, or null when it holds a build.