// 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//manifest.json the channel's posts manifest, pageCount 0 // posts//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 { isPrivateSite, type Site } from "../lib/siteSchema"; 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 — and no // size: `maxPageBytes` is 0 (nothing reads it), so a tombstone says nothing of // the withheld archive beyond how many empty pages stand in for it. export function tombstoneChannelManifest(slug: string, generatedAt: string): ChannelPostsManifest { return { version: POSTS_MANIFEST_VERSION, channelSlug: slug, pageCount: 0, maxPageBytes: 0, 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 of a channel's SHARED posts tree, or 0 when it has none or it // cannot be read. async function sharedPageCount(sharedPostsDir: string, slug: string): Promise { try { const m = JSON.parse( await readFile(path.join(sharedPostsDir, slug, "manifest.json"), "utf8"), ) as Partial; return Number.isInteger(m.pageCount) && (m.pageCount as number) > 0 ? (m.pageCount as number) : 0; } catch { return 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 { 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 = await sharedPageCount(opts.sharedPostsDir, slug); await writePublicFile( path.join(dir, "manifest.json"), JSON.stringify(tombstoneChannelManifest(slug, generatedAt)), ); 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//manifest.json`, `posts//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/*"]; } /** * The X channels a hub tombstones while X posts are private: every X channel * that has a SHARED posts tree AND is a member of at least one site whose * audience is not private — the only channels whose posts a public bundle (a * public site's, or a hub composed over one) could ever have served, so the * only paths the edge can still hold. PRIVATE DATA IS NEVER NAMED ON THE HUB: * an X channel carried only by private sites, or by no site, gets no * tombstone, because its slug in a public bundle would itself publish it. * Empty while X posts are public. */ export async function withdrawnXChannels( paths: Paths, settings: { social?: { x?: { visibility?: unknown } } }, sites: readonly Pick[], ): Promise { if (xPostsVisibility(settings) !== "private") return []; const onPublicSite = new Set(); for (const site of sites) { if (isPrivateSite(site)) continue; for (const c of site.channels) onPublicSite.add(c.slug); } 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 (!onPublicSite.has(slug)) continue; if (isXPostsChannel(await readChannelConfig(paths, slug))) out.push(slug); } return out; }