// Integration: X posts are private (release 17 slice XP), through the REAL // index build and the REAL site compose, over a temp corpus. // // One video channel, one X channel and one Bluesky channel, on two sites that // both have all three: `pub` (public) and `priv` (`audience: "private"`). With // `social.x.visibility` "private", the public site's build carries no X channel // at all — no posts manifest entry, no posts tree, no channel in its channel // list, site.json or corpus.json — while the private one carries everything // 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//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. // // Run with: node_modules/.bin/tsx --test common/bin/compose-site.postsVisibility.test.ts import { after, test } from "node:test"; import assert from "node:assert/strict"; import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; // Every path getPaths() can resolve to a place this file's code may write is // pinned under ROOT before anything calls it (buildIndex.test.ts's list). const ROOT = mkdtempSync(path.join(tmpdir(), "posts-visibility-")); const PINNED: Record = { TRANSCRIPTS_DIR: path.join(ROOT, "transcripts"), SAVED_VIDEOS_DIR: path.join(ROOT, "saved-videos"), SITES_DIR: path.join(ROOT, "transcripts", "sites"), SETTINGS_FILE: path.join(ROOT, "settings.json"), EXPORT_PUBLIC_DIR: path.join(ROOT, "public"), EXPORT_INDEX_DIR: path.join(ROOT, ".export-index"), EXPORT_BUILDS_DIR: path.join(ROOT, ".export-builds"), EDITOR_CHANGELOG_FILE: path.join(ROOT, "editor-CHANGELOG.md"), EXPORT_CHANGELOG_FILE: path.join(ROOT, "export-CHANGELOG.md"), CHARTS_CONFIG_FILE: path.join(ROOT, "chart-templates.json"), SEARCH_ALIASES_FILE: path.join(ROOT, "transcripts", "search-aliases.json"), CURATED_TAGS_FILE: path.join(ROOT, "transcripts", "tags.json"), ARCHILYZER_CONFIG_DIR: path.join(ROOT, "config"), ARCHILYZER_SOURCE_SCRATCH: path.join(ROOT, "source-scratch"), }; Object.assign(process.env, PINNED); after(() => rmSync(ROOT, { recursive: true, force: true })); const { getPaths } = await import("../lib/paths"); const { buildIndex } = await import("../controller/buildIndex"); const { writePosts } = await import("../lib/posts-server"); const { main: composeSite } = await import("./compose-site"); const { builtAudienceProblem, deployAudienceProblem } = await import("../lib/builtExport"); const paths = getPaths(); const VIDEOS = "vids"; const X = "jer-x"; const SKY = "jer-sky"; const HUB = "https://hub.example.test"; const writeJson = (file: string, value: unknown) => { mkdirSync(path.dirname(file), { recursive: true }); writeFileSync(file, JSON.stringify(value, null, 2)); }; const readJson = (file: string): T => JSON.parse(readFileSync(file, "utf8")) as T; // YouTube's rolling-caption shape (parseVtt keeps lines with inline timing). const VTT = "WEBVTT\nKind: captions\nLanguage: en\n\n" + "00:00:00.000 --> 00:00:05.000 align:start position:0%\n" + "First<00:00:01.000> caption<00:00:02.000> line.\n"; function post(slug: string, id: string, platform: "twitter" | "bluesky", text: string) { return { id, slug: `${slug}/${id}`, channelSlug: slug, author: slug, createdAt: "2026-09-01T12:00:00.000Z", uploadDate: "20260901", text, url: `https://example.test/${slug}/${id}`, platform, isReply: false, isRepost: false, links: [], }; } function writeSettings(visibility?: "public" | "private") { writeJson(paths.settingsFile, { homepageUrl: HUB, ...(visibility ? { social: { x: { visibility } } } : {}), }); } async function seedCorpus() { writeJson(path.join(paths.channelsDir, VIDEOS, "config.json"), { handling: "youtube", name: "Videos", url: "https://www.youtube.com/@vids/videos", }); const dir = path.join(paths.channelsDir, VIDEOS, "data", "v1"); writeJson(path.join(dir, "metadata.info.json"), { id: "v1", title: "Video one", channel: VIDEOS, upload_date: "20260601", duration: 120, webpage_url: "https://www.youtube.com/watch?v=v1", extractor_key: "Youtube", }); writeFileSync(path.join(dir, "transcript.en.vtt"), VTT); writeJson(path.join(paths.channelsDir, X, "config.json"), { handling: "youtube", name: "Jer on X", url: "https://x.com/jer", sourceKind: "social", platform: "twitter", socialHandle: "jer", }); await writePosts(path.join(paths.channelsDir, X), [ post(X, "1001", "twitter", "an x post"), post(X, "1002", "twitter", "another x post"), ]); writeJson(path.join(paths.channelsDir, SKY, "config.json"), { handling: "youtube", name: "Jer on Bluesky", url: "https://bsky.app/profile/jer.example", sourceKind: "social", platform: "bluesky", socialHandle: "jer.example", }); await writePosts(path.join(paths.channelsDir, SKY), [ post(SKY, "3kabc", "bluesky", "a bluesky post"), ]); const site = (siteId: string, extra: Record = {}) => writeJson(path.join(paths.sitesDir, siteId, "site.json"), { siteId, siteTitle: siteId, siteDescription: "fixture", headerTitle: siteId, homeTagline: "", socialLinks: [], groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", channels: [VIDEOS, X, SKY].map((slug) => ({ slug, groupId: "default" })), siteUrl: `https://${siteId}.example.test`, archives: false, ...extra, }); site("pub"); site("priv", { audience: "private" }); } const quiet = () => {}; async function index() { await buildIndex({ paths, onLog: quiet }); } // 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: { site: { id: string; hubUrl?: string; audience?: string }; channels: { slug: string; postCount?: number; manifests: { posts?: string } }[]; postScheme?: unknown; totals: { channels: number }; }; }; async function compose(siteId: string): Promise { const log = console.log; console.log = quiet; try { await composeSite({ siteId, paths }); } finally { console.log = log; } 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: 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")), }; } const slugs = (xs: { slug: string }[]) => xs.map((c) => c.slug).sort(); test("X private: a public site carries no X channel; a private site carries all of it and says so", async () => { await seedCorpus(); writeSettings("private"); await index(); // The index build's per-site manifests, before any compose. const sitePosts = (id: string) => readJson( path.join(paths.exportSitesIndexDir, id, "posts", "manifest.json"), ); assert.deepEqual(slugs(sitePosts("pub").channels), [SKY]); assert.deepEqual(slugs(sitePosts("priv").channels), [SKY, X]); // The shared posts tree is corpus-wide and keeps X: the private site and // the MCP over its build read it. assert.ok(existsSync(path.join(paths.exportSharedPostsDir, X, "manifest.json"))); const pub = await compose("pub"); 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]); assert.equal(pub.corpus.totals.channels, 2); assert.equal(pub.corpus.site.audience, undefined); assert.equal(pub.corpus.site.hubUrl, HUB); assert.equal(pub.siteJson.hubUrl, HUB); assert.equal(builtAudienceProblem(paths.exportPublicDir), null); assert.equal(deployAudienceProblem({ siteId: "pub" }, paths.exportPublicDir), null); 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); assert.equal(priv.corpus.site.audience, "private"); // A private site belongs under no hub. assert.equal(priv.corpus.site.hubUrl, undefined); assert.equal(priv.siteJson.hubUrl, undefined); // And its bundle refuses to deploy — under its own id, and under another // site's (a public site's identity on a private build is still refused). assert.match(builtAudienceProblem(paths.exportPublicDir) ?? "", /private build of "priv"/); assert.match( deployAudienceProblem({ siteId: "priv", audience: "private" }, paths.exportPublicDir) ?? "", /^Site "priv" is private \(audience: private\)/, ); // Compose the public site again over the private one's public/, with // nothing changed since its last compose: the X channel the private site // carried is pruned from every tree, and the summaries are the public // site's again — its compose cache is not trusted over another site's // compose (the summaries used to be skipped as "unchanged" and shipped the // 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]); assert.equal(again.corpus.site.audience, undefined); // And over its own last compose the cache is trusted as before. const third = await compose("pub"); assert.deepEqual(slugs(third.corpus.channels), [SKY, VIDEOS]); }); test("a config compose cannot read does not ship the posts tree the index build withheld", async () => { // The index build (X private) withheld X from pub's posts manifest; compose // then fails to read X's config, so its own rule reads X as visible. The // site posts manifest is the index build's word: no X posts tree ships. writeSettings("private"); await index(); const cfg = path.join(paths.channelsDir, X, "config.json"); const saved = readFileSync(cfg, "utf8"); rmSync(cfg); try { const pub = await compose("pub"); 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); } }); test("a compose over an index built before the setting flipped lists no X channel anywhere", async () => { // Index with X public, then flip to private and compose WITHOUT indexing. writeSettings("public"); await index(); 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); assert.equal(pub.corpus.postScheme !== undefined, true, "the Bluesky posts are still advertised"); // The channel list too: no X channel in site.json or corpus.json. assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]); assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]); }); test("X public again: the next build puts X back on the public site", async () => { writeSettings("public"); 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. writeSettings(); await index(); assert.deepEqual(slugs((await compose("pub")).postsManifest.channels), [SKY, X]); }); test("a public site whose only posts were X posts ships an empty posts manifest and no post scheme", async () => { writeSettings("private"); writeJson(path.join(paths.sitesDir, "xonly", "site.json"), { siteId: "xonly", siteTitle: "xonly", siteDescription: "fixture", headerTitle: "xonly", homeTagline: "", groups: [{ id: "default", name: "All channels", selectedByDefault: true }], defaultGroupId: "default", channels: [VIDEOS, X].map((slug) => ({ slug, groupId: "default" })), archives: false, }); await index(); const xonly = await compose("xonly"); // SearchSessionContext's hasPostsCorpus is `channels.length > 0`: no Posts // 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]); // With no posts manifest from the index at all, the one an earlier compose // left in public/ (here: listing the Bluesky channel) is replaced by an // empty one beside the tombstone. rmSync(path.join(paths.exportSitesIndexDir, "xonly", "posts", "manifest.json")); writeJson(path.join(paths.exportPostsDir, "manifest.json"), { version: 1, channels: [{ slug: SKY, name: SKY, postCount: 1, platform: "bluesky" }], totalCount: 1, generatedAt: "2026-01-01T00:00:00.000Z", }); const bare = await compose("xonly"); assert.deepEqual(bare.postsManifest.channels, []); assert.equal(bare.postsManifest.totalCount, 0); assert.deepEqual(bare.tombstones, [X]); assert.equal(bare.corpus.postScheme, undefined); });