Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 1862ed472d8229e8fe23da582331aaad606351ee
parent 19c7b28885f2fd998510f3e53d0c7f9d2ab650d5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 08:20:01 -0400

common: withdrawn X posts ship tombstones, served no-store (site and hub)

While social.x.visibility is private, compose-site replaces each withheld
member's posts paths with tombstones (publish/tombstones.ts): the channel's
posts manifest at pageCount 0 and `[]` for every page the shared tree holds,
plus an empty posts/manifest.json when the index wrote none. compose-hub
writes the same for every X channel with a shared posts tree, with an empty
posts/manifest.json, after removing SITE_ONLY_PUBLIC_ENTRIES. renderHeadersFile
takes `noStore`: `Cache-Control: no-store` for /posts/manifest.json and
/posts/<slug>/* on a site, /posts/* on the hub — with CORS only where no CORS
rule already covers the path (a repeated header is appended). builtHubProblem
accepts a posts/ of tombstones only (isTombstonePostsTree).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/bin/compose-hub.test.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-hub.ts | 56+++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcommon/bin/compose-site.postsVisibility.test.ts | 67+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/bin/compose-site.ts | 42+++++++++++++++++++++++++++++++++++++++---
Mcommon/lib/archive/headers.test.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/archive/headers.ts | 32++++++++++++++++++++++++++++++++
Mcommon/lib/builtExport.test.ts | 23+++++++++++++++++++++++
Mcommon/lib/builtExport.ts | 62+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acommon/publish/tombstones.test.ts | 163+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/tombstones.ts | 177+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 750 insertions(+), 11 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 { builtHubProblem } from "../lib/builtExport"; import { readGlobalAliases } from "../lib/aliasesStore"; // Run with: @@ -41,6 +42,9 @@ function fixturePaths(root: string): Paths { lmdbPath: path.join(root, "index.mdb"), // never created: no index exportPublicDir, exportIndexDir: path.join(root, ".export-index"), + // The hub's tombstones read the shared posts tree (release 18): this + // fixture's own, never the checkout's. + exportSharedPostsDir: path.join(root, ".export-index", "shared", "posts"), }; } @@ -263,3 +267,81 @@ test("compose-hub removes a site's data from public/, a linked entry by its link rmSync(root, { recursive: true, force: true }); } }); + +// Release 18: Cloudflare's edge kept serving the hub's withdrawn X shard after +// the deploy that removed it. While X posts are private the hub REPLACES those +// paths — path for path, the rollout's requirement: posts/manifest.json (empty), +// posts/thequartering-X/manifest.json (pageCount 0), posts/thequartering-X/ +// page-0000.json ([]) — and serves posts/ no-store. A Bluesky channel is not X +// and gets nothing; with X public the hub ships no posts/ at all. +test("while X posts are private, the hub ships tombstones for every X channel, served no-store", async () => { + const root = mkdtempSync(path.join(tmpdir(), "compose-hub-")); + const log = console.log; + try { + const paths = fixturePaths(root); + const pub = paths.exportPublicDir; + const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); + }; + const X = "thequartering-X"; + const SKY = "jer-sky"; + writeJson(path.join(paths.channelsDir, X, "config.json"), { + handling: "youtube", + url: "https://x.com/x", + sourceKind: "social", + platform: "twitter", + }); + writeJson(path.join(paths.channelsDir, SKY, "config.json"), { + handling: "youtube", + url: "https://bsky.app/profile/jer.example", + sourceKind: "social", + platform: "bluesky", + }); + for (const slug of [X, SKY]) { + writeJson(path.join(paths.exportSharedPostsDir, slug, "manifest.json"), { + version: 1, + channelSlug: slug, + pageCount: 1, + maxPageBytes: 1000, + generatedAt: "2026-10-01T00:00:00.000Z", + slugToPage: { "1": 0 }, + }); + writeJson(path.join(paths.exportSharedPostsDir, slug, "page-0000.json"), [{ id: "1", text: "a post" }]); + } + + console.log = () => {}; + await main({ paths, settings: { social: { x: { visibility: "private" } } } }); + console.log = log; + + const read = (rel: string) => JSON.parse(readFileSync(path.join(pub, rel), "utf8")); + assert.deepEqual(read("posts/manifest.json").channels, []); + assert.equal(read("posts/manifest.json").totalCount, 0); + assert.equal(read(`posts/${X}/manifest.json`).pageCount, 0); + assert.deepEqual(read(`posts/${X}/manifest.json`).slugToPage, {}); + assert.deepEqual(read(`posts/${X}/page-0000.json`), []); + assert.ok(!existsSync(path.join(pub, "posts", SKY)), "a Bluesky channel is not withdrawn"); + const headers = readFileSync(path.join(pub, "_headers"), "utf8"); + assert.ok( + headers.endsWith( + "# Withdrawn content (tombstones): never stored at the edge.\n" + + "/posts/*\n Cache-Control: no-store\n Access-Control-Allow-Origin: *\n", + ), + headers, + ); + // corpus.json advertises no posts; and the hub bundle (public/ stands in + // for out/) is still a hub: a posts/ of tombstones is its own. + assert.ok(!readFileSync(path.join(pub, "corpus.json"), "utf8").includes("posts")); + assert.equal(builtHubProblem(pub), null); + + // X public again: no posts/ at all, no no-store block. + console.log = () => {}; + await main({ paths, settings: {} }); + console.log = log; + assert.ok(!existsSync(path.join(pub, "posts"))); + assert.ok(!readFileSync(path.join(pub, "_headers"), "utf8").includes("no-store")); + } finally { + console.log = log; + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts @@ -13,6 +13,11 @@ // 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 +// public/posts/ <- TOMBSTONES only, while X posts are private: +// an empty posts/manifest.json and, per X +// channel with a shared posts tree, its +// manifest at pageCount 0 and `[]` pages +// (publish/tombstones.ts), served no-store // // 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. @@ -38,6 +43,14 @@ 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 { getSettings } from "../lib/settings"; +import { + emptyPostsManifest, + tombstoneNoStoreForHub, + withdrawnXChannels, + writePostsTombstones, + type PostsTombstone, +} from "../publish/tombstones"; import { runIfEntryPoint } from "./_cli"; import { writePublicFile } from "./_publicFile"; @@ -110,13 +123,44 @@ export const SITE_ONLY_PUBLIC_ENTRIES: readonly string[] = [ "sitemap.xml", ]; -export async function main(opts: { paths?: Paths } = {}): Promise<void> { +// THE HUB'S TOMBSTONES (release 18). A hub built over a site's compose once +// shipped that site's posts (the review's HIGH 1 above), and Cloudflare's edge +// kept serving them after the deploy that removed them. Removing is not enough +// at the edge, so while X posts are private the hub REPLACES every path an X +// channel's posts could have been served from: an empty posts manifest, and per +// X channel with a shared posts tree its manifest at pageCount 0 and an empty +// page for each page the shared tree holds now — all served no-store. With X +// posts public, or no X channel, the hub ships no posts/ at all, as before. +async function composeHubTombstones( + paths: Paths, + publicDir: string, + settings: { social?: { x?: { visibility?: unknown } } }, +): Promise<PostsTombstone[]> { + const slugs = await withdrawnXChannels(paths, settings); + if (slugs.length === 0) return []; + const postsDir = path.join(publicDir, "posts"); + const tombstones = await writePostsTombstones({ + postsDir, + sharedPostsDir: paths.exportSharedPostsDir, + slugs, + }); + await writePublicFile( + path.join(postsDir, "manifest.json"), + JSON.stringify(emptyPostsManifest(new Date().toISOString())), + ); + return tombstones; +} + +export async function main( + opts: { paths?: Paths; settings?: { social?: { x?: { visibility?: unknown } } } } = {}, +): 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 }); } + const tombstones = await composeHubTombstones(paths, publicDir, opts.settings ?? getSettings()); // 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. @@ -181,7 +225,9 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> { await writePublicFile( path.join(publicDir, "_headers"), - renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), + renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS, { + noStore: tombstoneNoStoreForHub(tombstones), + }), ); // The hub always ships a PWA. Copy the hub service worker into place. Until @@ -196,8 +242,12 @@ export async function main(opts: { paths?: Paths } = {}): Promise<void> { const summaryNote = await composeHubSummary(paths, publicDir); + const tombstoneNote = + tombstones.length > 0 + ? `; ${tombstones.length} withdrawn X channel(s) shipped as tombstones, served no-store` + : ""; console.log( - `compose-hub: ${builtins.length} built-in pool site(s) into ${publicDir}; ${summaryNote}.`, + `compose-hub: ${builtins.length} built-in pool site(s) into ${publicDir}; ${summaryNote}${tombstoneNote}.`, ); } diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts @@ -9,6 +9,12 @@ // and says `"audience": "private"` with no hubUrl. Flipping the setting back // is a rebuild. The rule itself is lib/postsVisibility.ts (its own tests). // +// Release 18: the public site's withheld X channel is not merely left out — its +// posts paths ship TOMBSTONES (publish/tombstones.ts): posts/<x>/manifest.json +// at pageCount 0 and `[]` for every page the shared tree holds, served no-store +// by the site's _headers. A tombstone is not a posts tree: `postTrees` below +// lists real trees only, `tombstones` the rest. +// // The export e2e cannot show this: its data is route-mocked, never built by // buildIndex and compose. export/e2e/x-posts-private.spec.ts serves the two // posts manifests this file pins and checks what a visitor sees. @@ -17,7 +23,15 @@ import { after, test } from "node:test"; import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -160,7 +174,10 @@ async function index() { // What one site's compose put in public/. type Composed = { postsManifest: { channels: { slug: string; platform: string; postCount: number }[]; totalCount: number }; + // Real posts trees (a channel manifest with pages), and tombstones (pageCount 0). postTrees: string[]; + tombstones: string[]; + headers: string; transcriptTrees: string[]; siteJson: { channels: { slug: string }[]; hubUrl?: string }; corpus: { @@ -181,9 +198,14 @@ async function compose(siteId: string): Promise<Composed> { const pub = paths.exportPublicDir; const trees = (dir: string) => [VIDEOS, X, SKY].filter((slug) => existsSync(path.join(dir, slug))); + const isTombstone = (slug: string) => + readJson<{ pageCount: number }>(path.join(pub, "posts", slug, "manifest.json")).pageCount === 0; + const posts = trees(path.join(pub, "posts")); return { postsManifest: readJson(path.join(pub, "posts", "manifest.json")), - postTrees: trees(path.join(pub, "posts")), + postTrees: posts.filter((slug) => !isTombstone(slug)), + tombstones: posts.filter(isTombstone), + headers: readFileSync(path.join(pub, "_headers"), "utf8"), transcriptTrees: trees(path.join(pub, "transcripts")), siteJson: readJson(path.join(pub, "site.json")), corpus: readJson(path.join(pub, "corpus.json")), @@ -212,6 +234,32 @@ test("X private: a public site carries no X channel; a private site carries all assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.equal(pub.postsManifest.totalCount, 1); assert.deepEqual(pub.postTrees, [SKY]); + // The withheld X channel's paths ship tombstones (release 18), path for path: + // its manifest at pageCount 0 and an empty page for the shared tree's one page. + assert.deepEqual(pub.tombstones, [X]); + const xDir = path.join(paths.exportPublicDir, "posts", X); + const sharedPages = readJson<{ pageCount: number }>( + path.join(paths.exportSharedPostsDir, X, "manifest.json"), + ).pageCount; + assert.equal(sharedPages, 1); + assert.deepEqual(readdirSync(xDir).sort(), ["manifest.json", "page-0000.json"]); + assert.deepEqual(readJson(path.join(xDir, "page-0000.json")), []); + const tomb = readJson<{ channelSlug: string; pageCount: number; slugToPage: object }>( + path.join(xDir, "manifest.json"), + ); + assert.equal(tomb.channelSlug, X); + assert.equal(tomb.pageCount, 0); + assert.deepEqual(tomb.slugToPage, {}); + assert.ok(!readFileSync(path.join(xDir, "page-0000.json"), "utf8").includes("x post")); + // …served no-store, after the CORS lines, with no second CORS header. + assert.ok( + pub.headers.endsWith( + "# Withdrawn content (tombstones): never stored at the edge.\n" + + "/posts/manifest.json\n Cache-Control: no-store\n" + + `/posts/${X}/*\n Cache-Control: no-store\n`, + ), + pub.headers, + ); assert.deepEqual(pub.transcriptTrees, [VIDEOS, SKY]); assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]); assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); @@ -225,7 +273,11 @@ test("X private: a public site carries no X channel; a private site carries all const priv = await compose("priv"); assert.deepEqual(slugs(priv.postsManifest.channels), [SKY, X]); assert.equal(priv.postsManifest.totalCount, 3); + // The private site carries the real tree where the public one had the + // tombstone, and withholds nothing: no tombstone, no no-store block. assert.deepEqual(priv.postTrees, [X, SKY]); + assert.deepEqual(priv.tombstones, []); + assert.ok(!priv.headers.includes("no-store")); assert.deepEqual(slugs(priv.corpus.channels), [SKY, X, VIDEOS]); assert.equal(priv.corpus.channels.find((c) => c.slug === X)?.postCount, 2); assert.ok(priv.corpus.channels.find((c) => c.slug === X)?.manifests.posts); @@ -249,6 +301,9 @@ test("X private: a public site carries no X channel; a private site carries all // private site's channel list). const again = await compose("pub"); assert.deepEqual(again.postTrees, [SKY]); + // The private site's real X tree is REPLACED by the tombstone, never left. + assert.deepEqual(again.tombstones, [X]); + assert.deepEqual(readJson(path.join(paths.exportPublicDir, "posts", X, "page-0000.json")), []); assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]); assert.deepEqual(slugs(again.siteJson.channels), [SKY, VIDEOS]); assert.deepEqual(slugs(again.corpus.channels), [SKY, VIDEOS]); @@ -272,6 +327,9 @@ test("a config compose cannot read does not ship the posts tree the index build assert.deepEqual(pub.postTrees, [SKY]); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); + // compose reads X as visible here (no config): no tombstone for it either, + // and no no-store block — the index build's word kept the tree out. + assert.deepEqual(pub.tombstones, []); } finally { writeFileSync(cfg, saved); } @@ -284,6 +342,7 @@ test("a compose over an index built before the setting flipped lists no X channe writeSettings("private"); const pub = await compose("pub"); assert.deepEqual(pub.postTrees, [SKY]); + assert.deepEqual(pub.tombstones, [X]); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]); assert.equal(pub.postsManifest.totalCount, 1); assert.equal(pub.corpus.channels.find((c) => c.slug === X)?.postCount, undefined); @@ -298,7 +357,10 @@ test("X public again: the next build puts X back on the public site", async () = await index(); const pub = await compose("pub"); assert.deepEqual(slugs(pub.postsManifest.channels), [SKY, X]); + // The tombstone is replaced by the real tree, and the no-store block goes. assert.deepEqual(pub.postTrees, [X, SKY]); + assert.deepEqual(pub.tombstones, []); + assert.ok(!pub.headers.includes("no-store")); assert.deepEqual(slugs(pub.corpus.channels), [SKY, X, VIDEOS]); // No setting at all is public too. @@ -326,6 +388,7 @@ test("a public site whose only posts were X posts ships an empty posts manifest // toggle on this site, with nothing special-cased. assert.deepEqual(xonly.postsManifest.channels, []); assert.deepEqual(xonly.postTrees, []); + assert.deepEqual(xonly.tombstones, [X]); assert.equal(xonly.corpus.postScheme, undefined); assert.deepEqual(slugs(xonly.corpus.channels), [VIDEOS]); }); diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts @@ -41,7 +41,7 @@ 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 { SITE_CORS_PATHS, renderHeadersFile } from "../lib/archive/headers"; import { effectiveSiteAliases } from "../lib/aliasesStore"; import { effectiveSiteTags } from "../lib/curatedTagsStore"; import { TAGS_FILENAME } from "../lib/curatedTags"; @@ -78,6 +78,11 @@ import { reportRoutes, type ComposedReports, } from "../publish/composeReports"; +import { + emptyPostsManifest, + tombstoneNoStoreForSite, + writePostsTombstones, +} from "../publish/tombstones"; import { runIfEntryPoint } from "./_cli"; import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; @@ -93,13 +98,17 @@ import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; // Exported, with emitAiFiles, for the cited fixture site's e2e staging // (export/e2e-report/stage.ts), which writes a cited site's contract around // fixture report views exactly as this compose would. +// +// `noStore` names the paths _headers serves uncached: the tombstones of X +// channels this site withheld (publish/tombstones.ts). export async function emitFederationFiles( site: Site, paths: ReturnType<typeof getPaths>, + opts: { noStore?: readonly string[] } = {}, ): Promise<void> { await writePublicFile( path.join(paths.exportPublicDir, "_headers"), - renderHeadersFile("compose-site.ts"), + renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: opts.noStore }), ); // A cited site has no summaries: its descriptor names no channel, and it is @@ -995,6 +1004,33 @@ export async function main( }), ); } + // --- tombstones for the X channels this site withheld (release 18) --- + // A withheld channel's posts were served from this site's paths before (or + // may still be cached at the edge from a build when X was public): every + // such path is REPLACED with an empty object of the same shape and served + // no-store, never left out (publish/tombstones.ts). The site posts manifest + // above already lists no withheld channel; a site whose only posts were X + // posts, with no manifest from the index, still ships an empty one. + const memberSet = new Set(memberSlugs); + const tombstones = await writePostsTombstones({ + postsDir: paths.exportPostsDir, + sharedPostsDir: paths.exportSharedPostsDir, + slugs: site.channels.map((c) => c.slug).filter((slug) => !memberSet.has(slug)), + }); + if (tombstones.length > 0) { + const postsManifest = path.join(paths.exportPostsDir, "manifest.json"); + if (!(await exists(postsManifest))) { + await writePublicFile( + postsManifest, + JSON.stringify(emptyPostsManifest(new Date().toISOString(), siteId)), + ); + } + console.log( + `[compose] posts: ${tombstones.length} withheld X channel(s) shipped as tombstones ` + + `(${tombstones.reduce((n, t) => n + t.pages, 0)} empty page(s)), served no-store.`, + ); + } + // Same for the per-site digests manifest (which channels carry digests). const digestsManifestSrc = path.join( paths.exportSitesIndexDir, @@ -1163,7 +1199,7 @@ export async function main( const composed = await composeReports({ paths, site, allowMissingMedia, log: console.log }); // --- federation contract: /site.json descriptor + CORS _headers --- - await emitFederationFiles(site, paths); + await emitFederationFiles(site, paths, { noStore: tombstoneNoStoreForSite(tombstones) }); // A site's bundle is not a hub's. export/public is shared with the hub build, // whose compose writes hub-sites.json; left in place it ships in this site's // out/ and makes the bundle ambiguous to builtHubProblem (and to a site's diff --git a/common/lib/archive/headers.test.ts b/common/lib/archive/headers.test.ts @@ -77,6 +77,63 @@ test("renderHeadersFile: the hub block, in full", () => { assert.equal(renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS), HUB_HEADERS); }); +// The tombstone blocks (release 18): withdrawn X posts are served no-store. A +// site's /posts/* CORS rule already covers them, so its no-store rules carry no +// second CORS header (a repeated header is APPENDED: "*, *"); the hub lists no +// posts tree, so its /posts/* rule carries both. +const SITE_TOMBSTONE_BLOCK = `# Withdrawn content (tombstones): never stored at the edge. +/posts/manifest.json + Cache-Control: no-store +/posts/jer-x/* + Cache-Control: no-store +`; + +const HUB_TOMBSTONE_BLOCK = `# Withdrawn content (tombstones): never stored at the edge. +/posts/* + Cache-Control: no-store + Access-Control-Allow-Origin: * +`; + +test("renderHeadersFile: a site's no-store block follows its CORS lines, with no second CORS header", () => { + assert.equal( + renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { + noStore: ["/posts/manifest.json", "/posts/jer-x/*"], + }), + SITE_HEADERS + SITE_TOMBSTONE_BLOCK, + ); + // No paths, no block: the file is byte-identical to the one without opts. + assert.equal(renderHeadersFile("compose-site.ts", SITE_CORS_PATHS, { noStore: [] }), SITE_HEADERS); +}); + +test("renderHeadersFile: the hub's /posts/* no-store rule carries its own CORS", () => { + assert.equal( + renderHeadersFile("compose-hub.ts", HUB_CORS_PATHS, { noStore: ["/posts/*"] }), + HUB_HEADERS + HUB_TOMBSTONE_BLOCK, + ); +}); + +test("no rendered file sets a header twice for one path", () => { + // Every pair of rules that both match a path must not both set the same + // header: wrangler appends the second value. + const files = [ + renderHeadersFile("s", SITE_CORS_PATHS, { noStore: ["/posts/manifest.json", "/posts/a/*"] }), + renderHeadersFile("h", HUB_CORS_PATHS, { noStore: ["/posts/*"] }), + ]; + for (const file of files) { + const rules: { path: string; headers: string[] }[] = []; + for (const line of file.split("\n")) { + if (line.startsWith("/")) rules.push({ path: line, headers: [] }); + else if (line.startsWith(" ")) rules[rules.length - 1].headers.push(line.trim().split(":")[0]); + } + const covers = (rule: string, p: string) => + rule.endsWith("*") ? p.startsWith(rule.slice(0, -1)) : rule === p; + for (const probe of ["/posts/manifest.json", "/posts/a/page-0000.json", "/corpus.json"]) { + const set = rules.filter((r) => covers(r.path, probe)).flatMap((r) => r.headers); + assert.equal(new Set(set).size, set.length, `${probe}: ${set.join(", ")}`); + } + } +}); + 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. diff --git a/common/lib/archive/headers.ts b/common/lib/archive/headers.ts @@ -71,17 +71,49 @@ export const HUB_CORS_PATHS: readonly string[] = [ "/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"; + +// 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. +// +// `noStore` adds, after the CORS lines, 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[] } = {}, ): string { const out = [`# Generated by ${generator} — do not edit by hand.`]; for (const p of paths) { out.push(p, ` ${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`; } diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts @@ -18,6 +18,7 @@ import { PUBLISH_MAX_FILE_BYTES, publishFileSizeProblem, reportHistoryProblem, + isTombstonePostsTree, } from "./builtExport"; function tempOut(siteJson?: string): { dir: string; cleanup: () => void } { @@ -125,6 +126,28 @@ test("builtHubProblem accepts only a hub bundle", () => { "export/out holds no hub build — build the hub first", ); + // Release 18: a posts/ of TOMBSTONES only is the hub's own (compose-hub + // writes them for withdrawn X posts) — and one real post in it is not. + mkdirSync(path.join(hub.dir, "posts", "jer-x"), { recursive: true }); + writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[],"totalCount":0}'); + writeFileSync( + path.join(hub.dir, "posts", "jer-x", "manifest.json"), + '{"channelSlug":"jer-x","pageCount":0,"slugToPage":{}}', + ); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), true); + assert.equal(builtHubProblem(hub.dir), null); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), '[{"id":"1"}]'); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); + assert.equal( + builtHubProblem(hub.dir), + "export/out holds a hub build that still carries a site's data (posts) — build the hub again", + ); + writeFileSync(path.join(hub.dir, "posts", "jer-x", "page-0000.json"), "[]"); + writeFileSync(path.join(hub.dir, "posts", "manifest.json"), '{"channels":[{"slug":"jer-x"}]}'); + assert.equal(isTombstonePostsTree(path.join(hub.dir, "posts")), false); + rmSync(path.join(hub.dir, "posts"), { recursive: true }); + // 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")); diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -421,8 +421,14 @@ export function builtHubProblem(outDir: string): string | null { } // 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))); + // that site's posts — a private site's included. Its one posts tree is the + // tombstones compose-hub writes for withdrawn X posts (release 18), and a + // tree holding anything but tombstones is a site's. + const carried = HUB_FORBIDDEN_TREES.filter( + (tree) => + existsSync(path.join(outDir, tree)) && + !(tree === "posts" && isTombstonePostsTree(path.join(outDir, tree))), + ); if (carried.length > 0) { return ( `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` + @@ -432,8 +438,58 @@ export function builtHubProblem(outDir: string): string | null { return null; } +/** + * Whether `postsDir` (a bundle's `posts/`) holds TOMBSTONES and nothing else + * (publish/tombstones.ts): a site posts manifest listing no channel, and per + * channel dir a posts manifest at pageCount 0 and only `[]` pages. Anything + * else — a post, a listed channel, an unreadable file, a stray entry — is not. + */ +export function isTombstonePostsTree(postsDir: string): boolean { + const readJson = (file: string): unknown => { + try { + return JSON.parse(readFileSync(file, "utf8")); + } catch { + return undefined; + } + }; + const manifest = readJson(path.join(postsDir, "manifest.json")) as + | { channels?: unknown } + | undefined; + if (!manifest || !Array.isArray(manifest.channels) || manifest.channels.length > 0) return false; + let entries: Dirent[]; + try { + entries = readdirSync(postsDir, { withFileTypes: true }); + } catch { + return false; + } + for (const e of entries) { + if (e.name === "manifest.json" && e.isFile()) continue; + if (!e.isDirectory()) return false; + const dir = path.join(postsDir, e.name); + const channel = readJson(path.join(dir, "manifest.json")) as + | { pageCount?: unknown; slugToPage?: unknown } + | undefined; + if (!channel || channel.pageCount !== 0) return false; + if (channel.slugToPage && Object.keys(channel.slugToPage as object).length > 0) return false; + let files: Dirent[]; + try { + files = readdirSync(dir, { withFileTypes: true }); + } catch { + return false; + } + for (const f of files) { + if (f.name === "manifest.json") continue; + if (!f.isFile() || !/^page-\d+\.json$/.test(f.name)) return false; + const page = readJson(path.join(dir, f.name)); + if (!Array.isArray(page) || page.length > 0) return false; + } + } + return true; +} + // The per-site data trees a hub bundle must never carry (the trees of -// compose-hub's SITE_ONLY_PUBLIC_ENTRIES). +// compose-hub's SITE_ONLY_PUBLIC_ENTRIES). A `posts/` of tombstones only is +// the hub's own (isTombstonePostsTree). const HUB_FORBIDDEN_TREES = [ "summaries", "transcripts", diff --git a/common/publish/tombstones.test.ts b/common/publish/tombstones.test.ts @@ -0,0 +1,163 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { Paths } from "../lib/paths"; +import { + emptyPostsManifest, + tombstoneChannelManifest, + tombstoneNoStoreForHub, + tombstoneNoStoreForSite, + tombstonePaths, + withdrawnXChannels, + writePostsTombstones, +} from "./tombstones"; + +// Run with: pnpm --filter yt-dlp-transcript-common test + +const writeJson = (file: string, value: unknown) => { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, JSON.stringify(value)); +}; +const readJson = (file: string) => JSON.parse(readFileSync(file, "utf8")); + +function sharedTree(shared: string, slug: string, pages: number) { + writeJson(path.join(shared, slug, "manifest.json"), { + version: 1, + channelSlug: slug, + pageCount: pages, + maxPageBytes: 4096, + generatedAt: "2026-10-01T00:00:00.000Z", + slugToPage: { a: 0 }, + }); + for (let i = 0; i < pages; i++) { + writeJson(path.join(shared, slug, `page-${String(i).padStart(4, "0")}.json`), [{ id: `p${i}` }]); + } +} + +test("the tombstone shapes: a channel manifest with no pages, a site manifest with no channels", () => { + assert.deepEqual(tombstoneChannelManifest("x", "T", 9), { + version: 1, + channelSlug: "x", + pageCount: 0, + maxPageBytes: 9, + generatedAt: "T", + slugToPage: {}, + }); + assert.deepEqual(emptyPostsManifest("T", "pub"), { + version: 1, + channels: [], + totalCount: 0, + generatedAt: "T", + siteId: "pub", + }); + assert.equal("siteId" in emptyPostsManifest("T"), false); +}); + +test("writePostsTombstones: one empty page per shared page, replacing a real tree, never through a link", async () => { + const root = mkdtempSync(path.join(tmpdir(), "tombstones-")); + try { + const shared = path.join(root, "shared", "posts"); + sharedTree(shared, "big-x", 3); + // A real tree an earlier public build shipped: replaced. + const posts = path.join(root, "public", "posts"); + writeJson(path.join(posts, "big-x", "page-0000.json"), [{ id: "p0", text: "a post" }]); + writeJson(path.join(posts, "big-x", "page-0007.json"), [{ id: "p7" }]); + // A linked tree (a worktree's public/ entries link into the primary's): + // the link goes, its target is untouched. + const primary = path.join(root, "primary", "posts", "linked-x"); + writeJson(path.join(primary, "page-0000.json"), [{ id: "keep" }]); + symlinkSync(primary, path.join(posts, "linked-x")); + + const written = await writePostsTombstones({ + postsDir: posts, + sharedPostsDir: shared, + slugs: ["big-x", "linked-x"], + generatedAt: "T", + }); + assert.deepEqual(written, [ + { slug: "big-x", pages: 3 }, + { slug: "linked-x", pages: 0 }, + ]); + assert.deepEqual(readdirSync(path.join(posts, "big-x")).sort(), [ + "manifest.json", + "page-0000.json", + "page-0001.json", + "page-0002.json", + ]); + for (const page of ["page-0000.json", "page-0001.json", "page-0002.json"]) { + assert.deepEqual(readJson(path.join(posts, "big-x", page)), []); + } + assert.deepEqual(readJson(path.join(posts, "big-x", "manifest.json")), tombstoneChannelManifest("big-x", "T", 4096)); + assert.deepEqual(readdirSync(path.join(posts, "linked-x")), ["manifest.json"]); + assert.deepEqual(readJson(path.join(primary, "page-0000.json")), [{ id: "keep" }]); + + assert.deepEqual(tombstonePaths(written, { withManifest: true }), [ + "posts/manifest.json", + "posts/big-x/manifest.json", + "posts/big-x/page-0000.json", + "posts/big-x/page-0001.json", + "posts/big-x/page-0002.json", + "posts/linked-x/manifest.json", + ]); + assert.deepEqual(tombstonePaths([]), []); + // Nothing to withdraw writes nothing — not even posts/. + const none = path.join(root, "none", "posts"); + assert.deepEqual(await writePostsTombstones({ postsDir: none, sharedPostsDir: shared, slugs: [] }), []); + assert.equal(existsSync(none), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("the no-store paths: a site names its manifest and each tombstone; the hub its whole posts tree", () => { + const t = [ + { slug: "a-x", pages: 1 }, + { slug: "b-x", pages: 0 }, + ]; + assert.deepEqual(tombstoneNoStoreForSite(t), ["/posts/manifest.json", "/posts/a-x/*", "/posts/b-x/*"]); + assert.deepEqual(tombstoneNoStoreForHub(t), ["/posts/*"]); + assert.deepEqual(tombstoneNoStoreForSite([]), []); + assert.deepEqual(tombstoneNoStoreForHub([]), []); +}); + +test("withdrawnXChannels: the X channels with a shared posts tree, only while X posts are private", async () => { + const root = mkdtempSync(path.join(tmpdir(), "tombstones-")); + try { + const paths = { + channelsDir: path.join(root, "channels"), + exportSharedPostsDir: path.join(root, "shared", "posts"), + } as Paths; + const config = (slug: string, platform: string) => + writeJson(path.join(paths.channelsDir, slug, "config.json"), { + handling: "youtube", + url: `https://example.test/${slug}`, + sourceKind: "social", + platform, + }); + config("z-x", "twitter"); + config("a-x", "twitter"); + config("sky", "bluesky"); + config("no-tree-x", "twitter"); + for (const slug of ["z-x", "a-x", "sky", "no-config"]) sharedTree(paths.exportSharedPostsDir, slug, 1); + + const priv = { social: { x: { visibility: "private" } } }; + assert.deepEqual(await withdrawnXChannels(paths, priv), ["a-x", "z-x"]); + assert.deepEqual(await withdrawnXChannels(paths, {}), []); + assert.deepEqual(await withdrawnXChannels(paths, { social: { x: { visibility: "public" } } }), []); + const empty = { ...paths, exportSharedPostsDir: path.join(root, "absent") } as Paths; + assert.deepEqual(await withdrawnXChannels(empty, priv), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/tombstones.ts b/common/publish/tombstones.ts @@ -0,0 +1,177 @@ +// TOMBSTONES FOR WITHDRAWN X POSTS (release 18). +// +// While `social.x.visibility` is "private", a public site leaves every X channel +// out whole (lib/postsVisibility.ts) and the hub carries no posts at all +// (compose-hub's SITE_ONLY_PUBLIC_ENTRIES). Leaving a path OUT of a deploy does +// not take it off Cloudflare's edge: Pages keeps serving a cached object until +// its TTL runs out, whatever the new deployment holds (the hub served a +// withdrawn X shard from a 7-day cache after the deploy that removed it). So +// withdrawn content is REPLACED, never deleted: at every path it was served +// from, the deploy ships an empty object of the same shape — +// +// posts/manifest.json a site posts manifest listing no channel +// (only when the site lists none at all) +// posts/<slug>/manifest.json the channel's posts manifest, pageCount 0 +// posts/<slug>/page-NNNN.json `[]`, for every N below the pageCount the +// shared posts tree has now +// +// — and lib/archive/headers.ts serves those paths `Cache-Control: no-store` +// (the paths are `tombstoneNoStore*` below). Nothing reads a tombstone: the +// site's posts manifest does not list the channel, so no reader asks for its +// tree, and corpus.json advertises no posts for it. A visitor holding a stale +// link gets an empty page instead of the post. +// +// The writers go through bin/_publicFile.ts: in a worktree, public/'s entries +// are links into the primary checkout, and nothing here writes through one. + +import path from "node:path"; +import { readdir, readFile, rm } from "node:fs/promises"; +import type { Paths } from "../lib/paths"; +import { + POSTS_MANIFEST_VERSION, + SITE_POSTS_MANIFEST_VERSION, + postsPageFileName, + type ChannelPostsManifest, + type PostsManifest, +} from "../lib/posts"; +import { isXPostsChannel, xPostsVisibility } from "../lib/postsVisibility"; +import { readChannelConfig } from "../controller/channels"; +import { ownDir, writePublicFile } from "../bin/_publicFile"; + +// One channel's tombstone: its slug and how many empty pages stand in for it. +export type PostsTombstone = { slug: string; pages: number }; + +// The channel posts manifest a tombstone ships: no pages, no posts. +export function tombstoneChannelManifest( + slug: string, + generatedAt: string, + maxPageBytes = 0, +): ChannelPostsManifest { + return { + version: POSTS_MANIFEST_VERSION, + channelSlug: slug, + pageCount: 0, + maxPageBytes, + generatedAt, + slugToPage: {}, + }; +} + +// The site posts manifest of a site that lists no posts channel. +export function emptyPostsManifest(generatedAt: string, siteId?: string): PostsManifest { + return { + version: SITE_POSTS_MANIFEST_VERSION, + channels: [], + totalCount: 0, + generatedAt, + ...(siteId ? { siteId } : {}), + }; +} + +// The pageCount (and page cap) of a channel's SHARED posts tree, or zeros when +// it has none or it cannot be read. +async function sharedPostsShape( + sharedPostsDir: string, + slug: string, +): Promise<{ pageCount: number; maxPageBytes: number }> { + try { + const m = JSON.parse( + await readFile(path.join(sharedPostsDir, slug, "manifest.json"), "utf8"), + ) as Partial<ChannelPostsManifest>; + const pageCount = Number.isInteger(m.pageCount) && (m.pageCount as number) > 0 ? (m.pageCount as number) : 0; + const maxPageBytes = typeof m.maxPageBytes === "number" ? m.maxPageBytes : 0; + return { pageCount, maxPageBytes }; + } catch { + return { pageCount: 0, maxPageBytes: 0 }; + } +} + +/** + * Write a tombstone tree for each of `slugs` under `postsDir` (a served + * `posts/`): the channel dir is replaced by its manifest at pageCount 0 and one + * `[]` page for every page the shared tree holds now. Returns what was written, + * in `slugs` order. + */ +export async function writePostsTombstones(opts: { + postsDir: string; + sharedPostsDir: string; + slugs: readonly string[]; + generatedAt?: string; +}): Promise<PostsTombstone[]> { + const generatedAt = opts.generatedAt ?? new Date().toISOString(); + const written: PostsTombstone[] = []; + if (opts.slugs.length === 0) return written; + await ownDir(opts.postsDir); + for (const slug of opts.slugs) { + const dir = path.join(opts.postsDir, slug); + // Whatever was there (a real tree a public build shipped before, or a + // linked one in a worktree) goes; rm removes a link, never its target. + await rm(dir, { recursive: true, force: true }); + await ownDir(dir); + const { pageCount, maxPageBytes } = await sharedPostsShape(opts.sharedPostsDir, slug); + await writePublicFile( + path.join(dir, "manifest.json"), + JSON.stringify(tombstoneChannelManifest(slug, generatedAt, maxPageBytes)), + ); + for (let i = 0; i < pageCount; i++) { + await writePublicFile(path.join(dir, postsPageFileName(i)), "[]"); + } + written.push({ slug, pages: pageCount }); + } + return written; +} + +/** + * The served paths a set of tombstones occupies, root-relative without a + * leading slash (`posts/<slug>/manifest.json`, `posts/<slug>/page-0000.json`, + * …), with `posts/manifest.json` first when `withManifest`. What a live check + * probes, and what a test pins. + */ +export function tombstonePaths( + tombstones: readonly PostsTombstone[], + opts: { withManifest?: boolean } = {}, +): string[] { + const out = opts.withManifest ? ["posts/manifest.json"] : []; + for (const t of tombstones) { + out.push(`posts/${t.slug}/manifest.json`); + for (let i = 0; i < t.pages; i++) out.push(`posts/${t.slug}/${postsPageFileName(i)}`); + } + return out; +} + +/** A site's no-store paths: its posts manifest and each tombstoned tree. */ +export function tombstoneNoStoreForSite(tombstones: readonly PostsTombstone[]): string[] { + if (tombstones.length === 0) return []; + return ["/posts/manifest.json", ...tombstones.map((t) => `/posts/${t.slug}/*`)]; +} + +/** The hub's no-store paths: its whole posts tree, which holds only tombstones. */ +export function tombstoneNoStoreForHub(tombstones: readonly PostsTombstone[]): string[] { + return tombstones.length === 0 ? [] : ["/posts/*"]; +} + +/** + * Every X channel that has a SHARED posts tree, while X posts are private — + * the channels a hub tombstones (it never carries posts, and once carried a + * site's). Empty while X posts are public. + */ +export async function withdrawnXChannels( + paths: Paths, + settings: { social?: { x?: { visibility?: unknown } } }, +): Promise<string[]> { + if (xPostsVisibility(settings) !== "private") return []; + let entries: string[]; + try { + entries = (await readdir(paths.exportSharedPostsDir, { withFileTypes: true })) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + } catch { + return []; + } + const out: string[] = []; + for (const slug of entries) { + if (isXPostsChannel(await readChannelConfig(paths, slug))) out.push(slug); + } + return out; +}