Archilyzer · Source

archilyzer

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

commit c93635a5aa0bad3820d626deb4d2d8222e9c3209
parent a210dace521bb77bc17e488119c7275a44fbdef1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 17:19:03 -0400

common: X posts are private — social.x.visibility, a site's audience, one predicate for both builds, and the deploy refusal

- lib/postsVisibility.ts (new, pure, tested): postsVisibleTo(site, config,
  settings) and publishedMemberSlugs — an X channel (social, platform
  twitter) is built only into private sites while social.x.visibility is
  "private"; every other channel is untouched.
- settings: social.x.visibility ("public" default | "private"), kept by
  sanitizeSocial beside cookieSource; SETTINGS.md regenerated.
- site.json: audience ("public" default | "private"; only private written);
  isListedSite is false for a private site; resolveHubUrl gives a private
  site no hubUrl; SITE.md regenerated.
- buildIndex's per-site loop and compose-site narrow the site's members by the
  one predicate (the corpus-wide shared posts tree is unchanged); the site
  fingerprint names withheld members; corpus.json says "audience": "private".
- builtExport: siteDeployProblem / builtAudienceProblem /
  deployAudienceProblem; runDeployIntoLog, the container deploy phase and
  deploySite refuse a private site or a private build before any upload.
- channel-sites.json maps a channel only to the sites whose build carries it;
  the export's offline page lists the same members.
- docker/publish-site.sh refuses a private site before building and a private
  build before publishing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
MSETTINGS.md | 3++-
MSITE.md | 7+++++++
Acommon/bin/compose-site.postsVisibility.test.ts | 287+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/compose-site.ts | 36+++++++++++++++++++++++++++++++++---
Mcommon/controller/buildIndex.ts | 19++++++++++++++++++-
Mcommon/controller/poolSummary.test.ts | 23+++++++++++++++++++++++
Mcommon/controller/poolSummary.ts | 28++++++++++++++++++++++++----
Mcommon/lib/builtExport.ts | 57+++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/corpus.ts | 7+++++++
Acommon/lib/postsVisibility.test.ts | 99+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/postsVisibility.ts | 75+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/settingsSchema.ts | 11++++++++++-
Mcommon/lib/site.ts | 6+++++-
Mcommon/lib/siteSchema.ts | 39++++++++++++++++++++++++++++++++++++---
Mcommon/publish/build.test.ts | 118+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/publish/build.ts | 33++++++++++++++++++++++++++++++++-
Mcommon/social/xCookieSource.ts | 29+++++++++++++++++++++++++----
Mdocker/publish-site.sh | 17+++++++++++++++++
Mexport/app/offline/page.tsx | 19++++++++++++++-----
19 files changed, 889 insertions(+), 24 deletions(-)

diff --git a/SETTINGS.md b/SETTINGS.md @@ -160,7 +160,7 @@ Default: `"when-required"` ## `social` -Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts. +Per-platform settings of the social posts. Today two keys, both X's, both chosen in the X account session section of /settings: where the X fetchers' login comes from (`social.x.cookieSource`) and where X posts may appear (`social.x.visibility`). See common/social/xCookieSource.ts. #### `social` @@ -173,6 +173,7 @@ Per-platform settings of the social-post fetchers. Today one key: where the X fe | Key | Default | Description | |---|---|---| | `cookieSource` | absent | Where the X fetchers' login comes from. `"browser"`: the operator's everyday browser, named by `cookiesFromBrowser` — gallery-dl is handed `--cookies-from-browser <spec>` and reads it on every run, and the Playwright fallback reads the same store (Firefox only; common/social/xBrowserLogin.ts), so the login lasts as long as the browser's. `"profile"`: the session broker's persistent profile ("Connect X account" on /settings) and the cookie jar it exports. ABSENT (the default) is resolved at read time, never stored: `"browser"` when `cookiesFromBrowser` is set and no profile is connected (no exported jar carrying an auth_token), else `"profile"`. The source, not `cookieMode`, governs the X fetchers. | +| `visibility` | absent | Where X posts may appear. `"public"` (the default; absent): an X channel's posts are built into every site that has the channel. `"private"`: every X channel's posts (a channel with `sourceKind: "social"` and `platform: "twitter"`) are left out of every PUBLIC site build — the channel with them, since posts are all an X channel holds — and built only into PRIVATE sites (`site.json` `audience`). Nothing on disk changes and fetching does not; a site already published changes on its next build and deploy, and flipping back is a rebuild. Chosen in the X account session section of /settings; the rule is common/lib/postsVisibility.ts. | Default: diff --git a/SITE.md b/SITE.md @@ -24,6 +24,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | [`accent`](#accent) | absent | | [`siteUrl`](#siteurl) | absent | | [`listed`](#listed) | `true` | +| [`audience`](#audience) | absent | | [`relatedSites`](#relatedsites) | `[]` | | [`pwa`](#pwa) | `false` | | [`archives`](#archives) | `true` | @@ -164,6 +165,12 @@ Whether the family lists this site. Opt-OUT: absent/true = listed, only an expli Default: `true` +## `audience` + +Who this site is built for. `"public"` (the default; absent) or `"private"`: the operator's own reading copy, built on this machine and never deployed — every deploy path (Build & deploy, Deploy, `archilyzer deploy site`, Build & deploy all, docker/publish-site.sh) refuses it before any upload, while a build without a deploy still works. A private site is never listed (as `listed: false`, whatever `listed` says), publishes no `hubUrl`, and its `/corpus.json` says `"audience": "private"`. Content kept from the public — X posts while `social.x.visibility` is `"private"` — is built only into private sites. Only `"private"` is written. + +Default: absent + ## `relatedSites` Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing "Other sites" group. Absent/empty = one flat list of every sibling. diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts @@ -0,0 +1,287 @@ +// 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). +// +// 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, 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<string, string> = { + 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 = <T>(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><c> caption</c><00:00:02.000><c> line.</c>\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<string, unknown> = {}) => + 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 }; + postTrees: 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<Composed> { + 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))); + return { + postsManifest: readJson(path.join(pub, "posts", "manifest.json")), + postTrees: trees(path.join(pub, "posts")), + 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<Composed["postsManifest"]>( + 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]); + 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); + assert.deepEqual(priv.postTrees, [X, SKY]); + 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/: the X + // channel it carried is pruned from every tree. + const again = await compose("pub"); + assert.deepEqual(again.postTrees, [SKY]); + assert.deepEqual(again.transcriptTrees, [VIDEOS, SKY]); + assert.equal(again.corpus.site.audience, undefined); +}); + +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]); + assert.deepEqual(pub.postTrees, [X, SKY]); + 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.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 @@ -60,6 +60,9 @@ import { type ArchiveManifest, type ArchiveManifestEntry, } from "../lib/archiveOptions"; +import { readChannelConfig } from "../controller/channels"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; +import { isPrivateSite } from "../lib/siteSchema"; import { runIfEntryPoint } from "./_cli"; import { copyPublicFile, ownDir, writePublicFile } from "./_publicFile"; @@ -98,7 +101,10 @@ async function emitFederationFiles( // navigate the already-served paginated shards; they never enumerate per-video // files, so the count is constant regardless of corpus size. Runs after // site.json and the archives are composed (both feed into these files). -async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> { +async function emitAiFiles( + site: Site, + paths: ReturnType<typeof getPaths>, +): Promise<void> { const sitePath = path.join(paths.exportPublicDir, "site.json"); if (!(await exists(sitePath))) return; // no composed data → nothing to describe const descriptor = JSON.parse( @@ -161,6 +167,9 @@ async function emitAiFiles(paths: ReturnType<typeof getPaths>): Promise<void> { postCounts, digestCounts, hasTags, + // A private site says so in its own corpus.json — the bundle's word that + // the deploy guard (lib/builtExport.ts builtAudienceProblem) reads. + private: isPrivateSite(site), }); await writePublicFile( path.join(paths.exportPublicDir, "corpus.json"), @@ -665,7 +674,28 @@ export async function main( } const paths = opts.paths ?? getPaths(); const site = getSite(siteId, paths); - const memberSlugs = site.channels.map((c) => c.slug); + // The members this site may publish (lib/postsVisibility.ts): every member, + // less an X channel while `social.x.visibility` is "private" and the site is + // public. The index build's per-site manifests are narrowed by the same rule; + // narrowing the trees here prunes an X channel a public site shipped before. + const configs = new Map( + await Promise.all( + site.channels.map( + async (c) => [c.slug, await readChannelConfig(paths, c.slug).catch(() => null)] as const, + ), + ), + ); + const memberSlugs = publishedMemberSlugs( + site, + (slug) => configs.get(slug), + getSettings(), + ); + const withheld = site.channels.length - memberSlugs.length; + if (withheld > 0) { + console.log( + `[compose] ${withheld} X channel(s) left out: X posts are private (social.x.visibility) and this site is public.`, + ); + } // Incremental compose: skip stages whose source is unchanged since last build. const cachePath = composeCachePath(paths, siteId); @@ -934,7 +964,7 @@ export async function main( // --- AI discovery: llms.txt / corpus.json / robots.txt / sitemap.xml --- // (after site.json + archives — both feed into these fixed-count files) - await emitAiFiles(paths); + await emitAiFiles(site, paths); // Persist the incremental-compose signatures for the next build. await writeComposeCache(cachePath, cache); diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -141,6 +141,7 @@ import { postsAvailabilityPath, } from "../lib/posts-server"; import { isSocialChannel } from "../lib/channelConfig"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; import { DIGESTS_MANIFEST_VERSION, SITE_DIGESTS_MANIFEST_VERSION, @@ -1940,8 +1941,21 @@ export async function buildIndex({ let aggregateSummaries = 0; let representativeChannelCount = 0; + // Which members a site may publish: an X channel is built only into private + // sites while `social.x.visibility` is "private" (lib/postsVisibility.ts — + // compose-site narrows its trees by the same rule). + const visibilitySettings = getSettings(); for (const site of sites) { - const memberSlugs = site.channels.map((c) => c.slug); + const memberSlugs = publishedMemberSlugs( + site, + (slug) => channelConfigs.get(slug), + visibilitySettings, + ); + // Members the rule leaves out of THIS build, named in the fingerprint below + // so flipping the setting (or the site's audience) rebuilds the site. + const withheld = site.channels + .map((c) => c.slug) + .filter((slug) => !memberSlugs.includes(slug)); const slugSet = new Set(memberSlugs); const slugGroup = new Map<string, string>(); for (const m of site.channels) { @@ -1972,6 +1986,9 @@ export async function buildIndex({ // yesterday's counts. curatedRules: curated.rulesHash, curatedAssign: curated.assignHash, + // Only when the visibility rule withholds a member, so a site it does + // not touch keeps the fingerprint it had. + ...(withheld.length > 0 ? { withheld } : {}), }); const fpKey = `siteFp:${site.siteId}`; if ( diff --git a/common/controller/poolSummary.test.ts b/common/controller/poolSummary.test.ts @@ -26,3 +26,26 @@ test("channel-sites.json names listed sites only; a channel only an unlisted sit }); assert.ok(!JSON.stringify(channelSitesOf(sites)).includes("fixture-unlisted")); }); + +// Release 17 slice XP: a PRIVATE site is never listed, so it is in neither; and +// with X posts private an X channel a public site leaves out of its build is +// not mapped to that site (buildPoolSummary passes the narrowing). +test("channel-sites.json leaves out a private site, and an X channel its public site withholds", async () => { + const { publishedMemberSlugs } = await import("../lib/postsVisibility"); + const sites = [ + parseSite("fixture-a", { channels: [{ slug: "vids" }, { slug: "jer-x" }] }), + parseSite("fixture-private", { + audience: "private", + channels: [{ slug: "vids" }, { slug: "jer-x" }, { slug: "own" }], + }), + ]; + const configs: Record<string, { sourceKind?: "social"; platform?: "twitter" }> = { + "jer-x": { sourceKind: "social", platform: "twitter" }, + }; + const privateX = { social: { x: { visibility: "private" } } }; + assert.deepEqual( + channelSitesOf(sites, (site) => publishedMemberSlugs(site, (slug) => configs[slug], privateX)), + { vids: ["fixture-a"] }, + ); + assert.deepEqual(channelSitesOf(sites), { vids: ["fixture-a"], "jer-x": ["fixture-a"] }); +}); diff --git a/common/controller/poolSummary.ts b/common/controller/poolSummary.ts @@ -12,6 +12,9 @@ import { mkdir, readFile } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import { buildStats } from "./buildStats"; import { isListedSite, listSites, type Site } from "../lib/site"; +import { getSettings } from "../lib/settings"; +import { publishedMemberSlugs } from "../lib/postsVisibility"; +import { readChannelConfig } from "./channels"; import { statsPageFileName, type StatsManifest, @@ -49,12 +52,21 @@ export async function readStatsPages(statsDir: string): Promise<VideoStat[]> { // published `channel-sites.json`. A channel on multiple sites maps to all of // them; a pool-only channel is simply absent, and so is an unlisted site // (site.json `listed: false`) and a channel only unlisted sites expose. -export function channelSitesOf(sites: readonly Site[]): ChannelSitesMap { +// +// `membersOf` answers the members a site's build publishes; absent, every +// member. buildPoolSummary passes lib/postsVisibility.ts's narrowing, so an X +// channel a public site leaves out while X posts are private is not mapped to +// that site here either. +export function channelSitesOf( + sites: readonly Site[], + membersOf: (site: Site) => readonly string[] = (site) => + site.channels.map((c) => c.slug), +): ChannelSitesMap { const channelSites: ChannelSitesMap = {}; for (const site of sites) { if (!isListedSite(site)) continue; - for (const c of site.channels) { - (channelSites[c.slug] ??= []).push(site.siteId); + for (const slug of membersOf(site)) { + (channelSites[slug] ??= []).push(site.siteId); } } return channelSites; @@ -79,7 +91,15 @@ export async function buildPoolSummary(opts: { // side effect, which is harmless. await buildStats({ paths, wholePoolStatsDir: statsDir }); const sites = listSites(paths); - const channelSites = channelSitesOf(sites); + // The members each site's build publishes (lib/postsVisibility.ts). + const configs = new Map<string, Awaited<ReturnType<typeof readChannelConfig>>>(); + for (const slug of new Set(sites.flatMap((s) => s.channels.map((c) => c.slug)))) { + configs.set(slug, await readChannelConfig(paths, slug).catch(() => null)); + } + const settings = getSettings(); + const channelSites = channelSitesOf(sites, (site) => + publishedMemberSlugs(site, (slug) => configs.get(slug), settings), + ); const stats = await readStatsPages(statsDir); const summary = buildHomepageSummary( stats, diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts @@ -98,6 +98,63 @@ export function builtBundleProblem(outDir: string, siteId: string): string | nul return null; } +/** + * Why `site` may not be deployed because of who it is built for, as one + * sentence — or null when it may (release 17 slice XP). + * + * A PRIVATE site (`site.json` `audience: "private"`) is the operator's own + * reading copy: it may carry what the public may not (X posts while + * `social.x.visibility` is "private"), so no deploy path ships it. Every + * deploy path asks this BEFORE ANY UPLOAD — the R2 archive push included — in + * the place it asks builtBundleProblem: runDeployIntoLog, the container deploy + * phase, deploySite, and the editor's Build & deploy, Deploy and Build & + * deploy all. A build without a deploy is untouched. + */ +export function siteDeployProblem(site: { + siteId: string; + audience?: string; +}): string | null { + if (site.audience !== "private") return null; + return ( + `Site "${site.siteId}" is private (audience: private): it is built for reading ` + + `on this machine and is never deployed. Build it without deploying, or set its ` + + `audience to public on its Settings tab` + ); +} + +/** + * Why the bundle in `outDir` may not be deployed because it was built PRIVATE + * — its corpus.json says `"audience": "private"` (compose-site writes it for a + * private site) — as one sentence, or null. Asked beside siteDeployProblem, so + * a site switched to public after a private build still cannot ship that + * build: it is rebuilt first. + */ +export function builtAudienceProblem(outDir: string): string | null { + try { + const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); + const site = (parsed as { site?: { audience?: unknown; id?: unknown } } | null)?.site; + if (site?.audience !== "private") return null; + const id = typeof site.id === "string" ? ` of "${site.id}"` : ""; + return ( + `${outDir} holds a private build${id} (its corpus.json says "audience": "private"), ` + + `which is never deployed` + ); + } catch { + return null; + } +} + +/** + * Both audience refusals, the site's first: the one sentence a deploy path + * logs or throws, or null. + */ +export function deployAudienceProblem( + site: { siteId: string; audience?: string }, + outDir: string, +): string | null { + return siteDeployProblem(site) ?? builtAudienceProblem(outDir); +} + // corpus.json's `site.id`, or null when there is no readable one. function corpusSiteIdIn(outDir: string): string | null { try { diff --git a/common/lib/corpus.ts b/common/lib/corpus.ts @@ -175,6 +175,9 @@ export type SiteCorpus = { description: string; url?: string; hubUrl?: string; + // Present only on a PRIVATE site's build (site.json `audience`, release 17 + // slice XP): the operator's own reading copy, which no deploy path ships. + audience?: "private"; }; totals: { channels: number; videos: number }; channels: CorpusChannel[]; @@ -229,6 +232,9 @@ export function buildSiteCorpus( // visible tag has a non-zero count here). Absent/false leaves corpus.json // shaped as before apart from the spec bump. hasTags?: boolean; + // A private site's build (site.json `audience: "private"`): corpus.json's + // `site.audience` says so. Absent/false leaves corpus.json as before. + private?: boolean; }, ): SiteCorpus { const base = descriptor.siteUrl; @@ -268,6 +274,7 @@ export function buildSiteCorpus( description: descriptor.siteDescription, ...(descriptor.siteUrl ? { url: descriptor.siteUrl } : {}), ...(descriptor.hubUrl ? { hubUrl: descriptor.hubUrl } : {}), + ...(opts.private ? { audience: "private" as const } : {}), }, totals: { channels: channels.length, videos }, channels, diff --git a/common/lib/postsVisibility.test.ts b/common/lib/postsVisibility.test.ts @@ -0,0 +1,99 @@ +// The one rule for which sites an X channel's posts are built into (release 17 +// slice XP). The build that applies it is pinned by +// bin/compose-site.postsVisibility.test.ts. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + isXPostsChannel, + postsVisibleTo, + publishedMemberSlugs, + xPostsVisibility, +} from "./postsVisibility"; +import { isListedSite, parseSite, siteToDisk } from "./siteSchema"; +import { sanitizeSocial } from "../social/xCookieSource"; + +const X = { sourceKind: "social" as const, platform: "twitter" as const }; +const BLUESKY = { sourceKind: "social" as const, platform: "bluesky" as const }; +const VIDEOS = { platform: "youtube" as const }; +const PRIVATE_X = { social: { x: { visibility: "private" } } }; +const PUBLIC_X = { social: { x: { visibility: "public" } } }; +const publicSite = { audience: undefined }; +const privateSite = { audience: "private" as const }; + +test("an X channel is a social channel on platform twitter, and nothing else is", () => { + assert.equal(isXPostsChannel(X), true); + assert.equal(isXPostsChannel(BLUESKY), false); + assert.equal(isXPostsChannel(VIDEOS), false); + // A video channel on X (were there one) is not a posts channel. + assert.equal(isXPostsChannel({ platform: "twitter" }), false); + assert.equal(isXPostsChannel(null), false); + assert.equal(isXPostsChannel(undefined), false); +}); + +test("social.x.visibility: absent and unknown read as public", () => { + assert.equal(xPostsVisibility({}), "public"); + assert.equal(xPostsVisibility({ social: { x: {} } }), "public"); + assert.equal(xPostsVisibility({ social: { x: { visibility: "hidden" } } }), "public"); + assert.equal(xPostsVisibility(PUBLIC_X), "public"); + assert.equal(xPostsVisibility(PRIVATE_X), "private"); +}); + +test("public: X posts go wherever the channel is a member", () => { + for (const settings of [{}, PUBLIC_X]) { + assert.equal(postsVisibleTo(publicSite, X, settings), true); + assert.equal(postsVisibleTo(privateSite, X, settings), true); + } +}); + +test("private: X posts go to private sites only; every other channel is untouched", () => { + assert.equal(postsVisibleTo(publicSite, X, PRIVATE_X), false); + assert.equal(postsVisibleTo({}, X, PRIVATE_X), false); + assert.equal(postsVisibleTo(privateSite, X, PRIVATE_X), true); + for (const site of [publicSite, privateSite]) { + assert.equal(postsVisibleTo(site, BLUESKY, PRIVATE_X), true); + assert.equal(postsVisibleTo(site, VIDEOS, PRIVATE_X), true); + assert.equal(postsVisibleTo(site, null, PRIVATE_X), true); + } +}); + +test("publishedMemberSlugs narrows the membership, in its order", () => { + const configs: Record<string, object> = { vids: VIDEOS, x: X, sky: BLUESKY }; + const channels = [{ slug: "x" }, { slug: "vids" }, { slug: "sky" }, { slug: "gone" }]; + const configOf = (slug: string) => configs[slug] as typeof X | undefined; + assert.deepEqual( + publishedMemberSlugs({ channels }, configOf, PRIVATE_X), + ["vids", "sky", "gone"], + ); + assert.deepEqual( + publishedMemberSlugs({ channels, audience: "private" }, configOf, PRIVATE_X), + ["x", "vids", "sky", "gone"], + ); + assert.deepEqual( + publishedMemberSlugs({ channels }, configOf, {}), + ["x", "vids", "sky", "gone"], + ); +}); + +test("site.json audience: only private is kept and written; a private site is never listed", () => { + assert.equal(parseSite("a", {}).audience, undefined); + assert.equal(parseSite("a", { audience: "public" }).audience, undefined); + assert.equal(parseSite("a", { audience: "secret" }).audience, undefined); + const priv = parseSite("a", { audience: "private" }); + assert.equal(priv.audience, "private"); + assert.equal(siteToDisk(priv).audience, "private"); + assert.equal("audience" in siteToDisk(parseSite("a", {})), false); + assert.equal(isListedSite(priv), false); + assert.equal(isListedSite({ ...priv, listed: true }), false); + assert.equal(isListedSite(parseSite("a", {})), true); +}); + +test("sanitizeSocial keeps visibility beside cookieSource and drops an unknown one", () => { + assert.deepEqual(sanitizeSocial({ x: { visibility: "private" } }), { + x: { visibility: "private" }, + }); + assert.deepEqual( + sanitizeSocial({ x: { cookieSource: "browser", visibility: "public" } }), + { x: { cookieSource: "browser", visibility: "public" } }, + ); + assert.deepEqual(sanitizeSocial({ x: { visibility: "nobody" } }), { x: {} }); +}); diff --git a/common/lib/postsVisibility.ts b/common/lib/postsVisibility.ts @@ -0,0 +1,75 @@ +// WHICH SITES A CHANNEL'S POSTS ARE BUILT INTO (release 17 slice XP). +// +// THE ONE RULE, asked by both places a site's posts are decided: +// - the index build's per-site loop (controller/buildIndex.ts), which writes +// each site's summaries, subs, posts and digests manifests from the site's +// member channels; +// - the site compose (bin/compose-site.ts), which copies the shared +// per-channel trees (transcripts, subs, posts, digests) of those members +// into the served public dir and writes /site.json and /corpus.json. +// Both narrow the site's members through `publishedMemberSlugs`, so the +// manifests and the trees can never disagree about a channel. +// +// The rule: with `social.x.visibility` "private", an X channel (a social +// channel on platform "twitter") is built only into a PRIVATE site +// (`site.json` `audience: "private"`). Posts are all a social channel holds — it +// has no videos (buildIndex's scan skips it) — so a public site leaves the +// channel out whole: no posts tree, no posts-manifest entry, no empty channel +// in its channel list or its corpus.json. Every other channel, and every +// channel on a private site, is unaffected. Nothing on disk changes and +// fetching does not: the shared posts tree (exportSharedPostsDir) and the LMDB +// posts sub-DB are corpus-wide and keep every channel, which is what a private +// site and the MCP over a private build read. +// +// The Search in row's Posts toggle needs no rule of its own: it shows only +// when the site's posts manifest lists a channel (SearchSessionContext +// hasPostsCorpus), so a public site whose only posts were X posts ships an +// empty posts manifest and no Posts toggle. +// +// Pure: no fs, no settings read. The callers pass the settings and the configs. + +import { isSocialChannel, type ChannelConfig } from "./channelConfig"; +import { isPrivateSite, type Site } from "./siteSchema"; +import type { XPostsVisibility } from "../social/xCookieSource"; + +// An X channel: the posts of a social channel whose platform is X. +export function isXPostsChannel( + config: Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, +): boolean { + return isSocialChannel(config) && config?.platform === "twitter"; +} + +// `social.x.visibility`, resolved: absent (or anything unknown) is "public". +export function xPostsVisibility(settings: { + social?: { x?: { visibility?: unknown } }; +}): XPostsVisibility { + return settings.social?.x?.visibility === "private" ? "private" : "public"; +} + +// Whether `site` may carry the posts of the channel `config` describes. A +// channel with no readable config is not an X channel as far as this rule +// knows, and is left to whatever already decides its fate. +export function postsVisibleTo( + site: Pick<Site, "audience">, + config: Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, + settings: { social?: { x?: { visibility?: unknown } } }, +): boolean { + if (!isXPostsChannel(config)) return true; + if (xPostsVisibility(settings) === "public") return true; + return isPrivateSite(site); +} + +// The site's member slugs, in membership order, less the channels whose posts +// it may not carry (an X channel holds nothing else). `configOf` answers a +// slug's channel config, or null/undefined when it has none. +export function publishedMemberSlugs( + site: Pick<Site, "audience" | "channels">, + configOf: ( + slug: string, + ) => Pick<ChannelConfig, "sourceKind" | "platform"> | null | undefined, + settings: { social?: { x?: { visibility?: unknown } } }, +): string[] { + return site.channels + .map((c) => c.slug) + .filter((slug) => postsVisibleTo(site, configOf(slug), settings)); +} diff --git a/common/lib/settingsSchema.ts b/common/lib/settingsSchema.ts @@ -116,6 +116,15 @@ export const X_SOCIAL_SETTINGS_FIELD_DOCS: FieldDocs<XSocialSettings> = { "stored: `\"browser\"` when `cookiesFromBrowser` is set and no profile is connected (no " + "exported jar carrying an auth_token), else `\"profile\"`. The source, not `cookieMode`, " + "governs the X fetchers.", + visibility: + "Where X posts may appear. `\"public\"` (the default; absent): an X channel's posts are " + + "built into every site that has the channel. `\"private\"`: every X channel's posts (a " + + "channel with `sourceKind: \"social\"` and `platform: \"twitter\"`) are left out of every " + + "PUBLIC site build — the channel with them, since posts are all an X channel holds — and " + + "built only into PRIVATE sites (`site.json` `audience`). Nothing on disk changes and " + + "fetching does not; a site already published changes on its next build and deploy, and " + + "flipping back is a rebuild. Chosen in the X account session section of /settings; the " + + "rule is common/lib/postsVisibility.ts.", }; export type { AutoQueueSettings } from "./autoQueueTypes"; export type { ChannelPriority } from "./channelPriority"; @@ -1477,7 +1486,7 @@ export const siteSettingsSchema = z.object({ "How yt-dlp invocations use the configured cookies (see common/lib/cookiePolicy.ts): \"always\" passes them on every invocation, \"when-required\" (default; the historical behavior) only to retry an auth/age failure, \"defer\" never in normal runs — auth-gated videos are excluded from batches and collected into the per-channel \"Needs cookies\" bucket for a manual cookie run. Per-channel override available (ChannelConfig.cookieMode).", ), social: settingsField((v): SocialSettings => sanitizeSocial(v)).describe( - "Per-platform settings of the social-post fetchers. Today one key: where the X fetchers' login comes from (`social.x.cookieSource`, chosen in the X account session section of /settings). See common/social/xCookieSource.ts.", + "Per-platform settings of the social posts. Today two keys, both X's, both chosen in the X account session section of /settings: where the X fetchers' login comes from (`social.x.cookieSource`) and where X posts may appear (`social.x.visibility`). See common/social/xCookieSource.ts.", ), sleepBetweenDownloadsSeconds: settingsField((v): number => clampSleepBetweenDownloadsSeconds(v)).describe( "Pause (seconds) inserted between per-video yt-dlp invocations in managed batch downloads. yt-dlp's own `-t sleep` only paces requests within one invocation, so without this the managed loop hammers the source IP back-to-back. 0 disables. Per-channel override available.", diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -14,6 +14,7 @@ import { socialLinksForSave } from "./socialLinks"; import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server"; import { isListedSite, + isPrivateSite, isValidSiteId, parseSite, parseSiteUrl, @@ -87,11 +88,14 @@ export function siteStatsDir(paths: Paths, siteId: string): string { } // The hub URL this site points visitors toward: its own override, else the -// family default (SiteSettings.homepageUrl). Undefined when neither is set. +// family default (SiteSettings.homepageUrl). Undefined when neither is set — +// and always for a PRIVATE site (`audience: "private"`), which belongs under no +// hub: a hub tells its members by the hubUrl they publish. export function resolveHubUrl( site: Site, settings: SiteSettings = getSettings(), ): string | undefined { + if (isPrivateSite(site)) return undefined; return site.hubUrl ?? parseSiteUrl(settings.homepageUrl); } diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -68,6 +68,27 @@ export const RELATED_SITE_GROUP_FIELD_DOCS: FieldDocs<RelatedSiteGroup> = { "Sibling site ids, in display order. Invalid and repeated ids are dropped, and a group left with none is dropped. Ids are resolved against the live pool at render time, so an id for a site that does not exist (yet) is harmless — it is skipped.", }; +// Who a site is built for (release 17 slice XP). "public" (the default, never +// written) is every site there has ever been. "private" is the operator's own +// reading copy: never deployed (publish/build.ts asks siteDeployProblem in +// lib/builtExport.ts before any upload), never listed (isListedSite below), +// publishing no hubUrl (lib/site.ts resolveHubUrl), and the only kind of site +// that content kept from the public (X posts while `social.x.visibility` is +// "private", lib/postsVisibility.ts) is built into. +export type SiteAudience = "public" | "private"; + +export const SITE_AUDIENCES: readonly SiteAudience[] = ["public", "private"]; + +export function isSiteAudience(v: unknown): v is SiteAudience { + return v === "public" || v === "private"; +} + +// THE ONE PREDICATE for a private site. Absent or anything but "private" reads +// as public. +export function isPrivateSite(site: Pick<Site, "audience">): boolean { + return site.audience === "private"; +} + // A Site is a selection + presentation layer over the single global channel // pool. Each field is documented in SITE_FIELD_DOCS below. export type Site = { @@ -85,6 +106,7 @@ export type Site = { accent?: string; siteUrl?: string; listed?: boolean; + audience?: SiteAudience; relatedSites?: RelatedSiteGroup[]; pwa?: boolean; archives?: boolean; @@ -119,6 +141,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { "Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.", listed: "Whether the family lists this site. Opt-OUT: absent/true = listed, only an explicit `false` is written. An unlisted site still builds and deploys as before, and its own pages are unchanged; it is left out of the homepage (cards, chart, `/stats`), the hub (members, federated search, `/corpus.json`, `/llms.txt`), every other site's footer, and the published `channel-sites.json` and pooled `stats/`. A channel only unlisted sites expose is in none of the family's public totals; a channel a listed site also exposes is credited to the listed one.", + audience: + 'Who this site is built for. `"public"` (the default; absent) or `"private"`: the operator\'s own reading copy, built on this machine and never deployed — every deploy path (Build & deploy, Deploy, `archilyzer deploy site`, Build & deploy all, docker/publish-site.sh) refuses it before any upload, while a build without a deploy still works. A private site is never listed (as `listed: false`, whatever `listed` says), publishes no `hubUrl`, and its `/corpus.json` says `"audience": "private"`. Content kept from the public — X posts while `social.x.visibility` is `"private"` — is built only into private sites. Only `"private"` is written.', relatedSites: "Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing \"Other sites\" group. Absent/empty = one flat list of every sibling.", pwa: @@ -150,8 +174,11 @@ export function isValidSiteId(id: unknown): id is string { // (lib/site.ts resolveRelatedSites). The editor's own pages list every site. // Here, beside the key, and exported from lib/site like isValidSiteId, so the // pure summary builder can use it without importing file I/O. -export function isListedSite(site: Pick<Site, "listed">): boolean { - return site.listed !== false; +// +// A PRIVATE site (`audience: "private"`) is never listed, whatever `listed` +// says: it is never deployed, so there is nothing at its URL to list. +export function isListedSite(site: Pick<Site, "listed" | "audience">): boolean { + return site.listed !== false && !isPrivateSite(site); } // The channels whose content belongs to unlisted sites alone: exposed by at @@ -160,7 +187,7 @@ export function isListedSite(site: Pick<Site, "listed">): boolean { // and a channel no site exposes (pool-only) is not here either — the family's // instance-wide totals have always counted it. export function channelsOnlyOnUnlistedSites( - sites: readonly Pick<Site, "listed" | "channels">[], + sites: readonly Pick<Site, "listed" | "audience" | "channels">[], ): Set<string> { const onListed = new Set<string>(); const onUnlisted = new Set<string>(); @@ -281,6 +308,10 @@ export const siteFieldsSchema = z.object({ siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl), // Opt-out: only an explicit false unlists. Absent/true stays listed. listed: settingsField((v): boolean => v !== false).describe(d.listed), + // Only "private" is kept; absent (and anything else) is the public default. + audience: settingsField((v): SiteAudience | undefined => + v === "private" ? "private" : undefined, + ).describe(d.audience), relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites), pwa: settingsField((v): boolean => v === true).describe(d.pwa), // Opt-out: only an explicit false disables. Absent/true stays on. @@ -372,6 +403,8 @@ export function siteToDisk(site: Site): Site { ...(siteUrl ? { siteUrl } : {}), // Listed is the default: only the opt-out is persisted. ...(site.listed === false ? { listed: false } : {}), + // Public is the default: only the private audience is persisted. + ...(isPrivateSite(site) ? { audience: "private" as const } : {}), ...(relatedSites.length > 0 ? { relatedSites } : {}), ...(site.pwa ? { pwa: true } : {}), // Persist only the non-default: archives is on unless explicitly disabled. diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts @@ -23,6 +23,7 @@ import { homepageDeployArgs, homepageOutDir, deployHomepage, + deploySite, dockerSiteOutDir, dockerSiteStagingDir, resolveOutDir, @@ -393,3 +394,120 @@ test("runDeployIntoLog refuses a bundle that is not the site's own before wrangl rmSync(root, { recursive: true, force: true }); } }); + +// A PRIVATE site (site.json `audience: "private"`, release 17 slice XP) is never +// deployed, and neither is a bundle built private (its corpus.json says so): +// refused at the same door as the wrong-site bundle, before wrangler, in words +// naming the audience. The fake `pnpm` is the test above's. +function writePrivateBundle(dir: string, siteId: string): void { + writeBundle(dir, siteId, siteId); + writeFileSync( + path.join(dir, "corpus.json"), + JSON.stringify({ site: { id: siteId, audience: "private" } }), + ); +} + +test("runDeployIntoLog refuses a private site and a private build before wrangler", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-private-")); + const bin = path.join(root, "bin"); + const argvFile = path.join(root, "pnpm-argv"); + mkdirSync(bin); + writeFileSync(path.join(bin, "pnpm"), `#!/bin/sh\nprintf '%s\\n' "$@" >> '${argvFile}'\n`); + chmodSync(path.join(bin, "pnpm"), 0o755); + const savedPath = process.env.PATH; + process.env.PATH = bin; + const signal = new AbortController().signal; + const testPaths = { ...paths, exportDir: root } as Paths; + try { + await runChildIntoLog(() => {}, signal, { command: "pnpm", args: ["--fake?"], cwd: root, env: { ...process.env } }); + assert.equal(readFileSync(argvFile, "utf8"), "--fake?\n"); + rmSync(argvFile); + + // The site is private: its own, well-formed bundle is still refused. + const own = path.join(root, "own", "out"); + writeBundle(own, "mine", "mine"); + const priv = { siteId: "mine", cloudflareProject: "w3c-never-real", audience: "private" } as Site; + let log: string[] = []; + assert.equal(await runDeployIntoLog((l) => log.push(l), signal, priv, own, testPaths), 1); + assert.equal(log.length, 1, log.join("")); + assert.match( + log[0], + /^\[deploy\] REFUSED — Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\./, + ); + assert.match(log[0], /Nothing was sent to Cloudflare Pages\.\n$/); + + // The site is public now, but the bundle was built private. + const built = path.join(root, "built", "out"); + writePrivateBundle(built, "mine"); + log = []; + const pub = { siteId: "mine", cloudflareProject: "w3c-never-real" } as Site; + assert.equal(await runDeployIntoLog((l) => log.push(l), signal, pub, built, testPaths), 1); + assert.match(log[0], /holds a private build of "mine" \(its corpus\.json says "audience": "private"\)/); + assert.equal(existsSync(argvFile), false, "pnpm was spawned"); + } finally { + process.env.PATH = savedPath; + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runDockerDeployAllPhase skips a private site before the upload, in the audience's words", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-all-private-")); + try { + const outFor = (id: string) => path.join(root, id, "out"); + writeBundle(outFor("mine"), "mine", "mine"); + writePrivateBundle(outFor("built"), "built"); + const log: string[] = []; + // A Cloudflare project on each: without the audience check the run would + // reach the upload, which the log would show. + const sites = [ + { siteId: "mine", audience: "private", cloudflareProject: "w3c-never-real" }, + { siteId: "built", cloudflareProject: "w3c-never-real" }, + ] as Site[]; + const outcomes = await runDockerDeployAllPhase( + (l) => log.push(l), + new AbortController().signal, + sites, + new Set(["mine", "built"]), + { ...paths, exportBuildsDir: root } as Paths, + outFor, + ); + assert.deepEqual(outcomes.map((o) => [o.siteId, o.status]), [["mine", "skipped"], ["built", "skipped"]]); + assert.match(outcomes[0].reason!, /^Site "mine" is private \(audience: private\)/); + assert.match(outcomes[1].reason!, /private build of "built"/); + assert.ok(log.some((l) => l.startsWith("[mine] deploy skipped — Site \"mine\" is private")), log.join("\n")); + assert.ok(!log.some((l) => l.startsWith("=== Deploy")), "nothing reached the deploy"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("deploySite (archilyzer deploy site) refuses a private site before anything, and a private build before the upload", async () => { + const root = mkdtempSync(path.join(os.tmpdir(), "deploy-site-private-")); + try { + const sitesDir = path.join(root, "sites"); + const site = (id: string, extra: Record<string, unknown> = {}) => { + mkdirSync(path.join(sitesDir, id), { recursive: true }); + writeFileSync( + path.join(sitesDir, id, "site.json"), + JSON.stringify({ siteId: id, cloudflareProject: "w3c-never-real", ...extra }), + ); + }; + site("mine", { audience: "private" }); + site("other"); + const testPaths = { ...paths, exportDir: root, sitesDir } as Paths; + const log: string[] = []; + await assert.rejects( + deploySite("mine", { paths: testPaths, onLog: (l) => log.push(l) }), + /^Error: Site "mine" is private \(audience: private\): it is built for reading on this machine and is never deployed\. Build it without deploying, or set its audience to public on its Settings tab\.$/, + ); + // Public, but export/out holds a private build of it. + writePrivateBundle(path.join(root, "out"), "other"); + await assert.rejects( + deploySite("other", { paths: testPaths, onLog: (l) => log.push(l) }), + /private build of "other".*Build other again, then deploy\.$/, + ); + assert.deepEqual(log, [], "nothing was logged: no upload, no deploy"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/common/publish/build.ts b/common/publish/build.ts @@ -14,7 +14,14 @@ import { createReadStream, existsSync } from "node:fs"; import { S3Client, HeadObjectCommand } from "@aws-sdk/client-s3"; import { Upload } from "@aws-sdk/lib-storage"; import { runChildIntoLog } from "../jobs/runChild"; -import { builtBundleProblem, builtHubProblem, builtSiteProblem } from "../lib/builtExport"; +import { + builtAudienceProblem, + builtBundleProblem, + builtHubProblem, + builtSiteProblem, + deployAudienceProblem, + siteDeployProblem, +} from "../lib/builtExport"; import { getHomepageConfig } from "../lib/homepage"; import { deploymentUrlIn, @@ -340,6 +347,16 @@ export async function runDeployIntoLog( // job (a build of another site, the hub) can rewrite export/out. Nothing runs // between this check and the spawn. The hub and the homepage deploy through // runPagesDeployIntoLog and never come here. + // + // A PRIVATE site, or a bundle built private, is refused first: it is never + // deployed, whatever the bundle's identity (deployAudienceProblem). + const audienceProblem = deployAudienceProblem(site, outDir); + if (audienceProblem) { + onLog( + `[deploy] REFUSED — ${audienceProblem}. Nothing was sent to Cloudflare Pages.\n`, + ); + return 1; + } const bundleProblem = builtBundleProblem(outDir, site.siteId); if (bundleProblem) { onLog( @@ -608,6 +625,14 @@ export async function runDockerDeployAllPhase( // the check build-site.sh makes before it hands the bundle back, made again // over whatever the per-site dir holds now. First, so that nothing past it // (the R2 upload, the Pages deploy) is ever reached with another site's data. + // A private site (or a private build) is not deployed at all: skipped, in + // its own words, so a family with one private site does not fail every run. + const audienceProblem = deployAudienceProblem(site, outDirFor(site.siteId)); + if (audienceProblem) { + onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`); + outcomes.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem }); + continue; + } const bundleProblem = builtBundleProblem(outDirFor(site.siteId), site.siteId); if (bundleProblem) { onLog(`[${site.siteId}] deploy REFUSED — ${bundleProblem}`); @@ -753,6 +778,9 @@ export async function deploySite( } const branch = opts.previewBranch?.trim() || undefined; const site = getSite(siteId.trim(), paths); + // Before anything else is asked of a private site: it is never deployed. + const privateProblem = siteDeployProblem(site); + if (privateProblem) throw new Error(`${privateProblem}.`); if (!site.cloudflareProject) { throw new Error( `Site "${site.siteId}" has no Cloudflare Pages project configured.`, @@ -761,6 +789,9 @@ export async function deploySite( const outDir = resolveOutDir(site.siteId, paths); const builtProblem = builtSiteProblem(outDir, site.siteId); if (builtProblem) throw new Error(builtProblem); + // Before the R2 upload below: a bundle built private is never deployed. + const builtPrivate = builtAudienceProblem(outDir); + if (builtPrivate) throw new Error(`${builtPrivate}. Build ${site.siteId} again, then deploy.`); // The production path logs no banner and gains none here: its log has // always opened on wrangler's own first line. if (branch) { diff --git a/common/social/xCookieSource.ts b/common/social/xCookieSource.ts @@ -27,11 +27,28 @@ export function isXCookieSource(v: unknown): v is XCookieSource { return v === "browser" || v === "profile"; } -// The `social` block of settings.json. Only X has a login to choose today; the -// block is per platform so a second one does not need a second top-level key. +// WHERE X POSTS MAY APPEAR — `social.x.visibility` (release 17 slice XP). +// "public" — the default (absent): an X channel's posts are built into every +// site that has the channel, as every other channel's are. +// "private" — an X channel's posts are left out of every PUBLIC site build and +// built only into PRIVATE sites (`site.json` `audience`). Nothing +// on disk changes, and fetching does not; flipping back is a +// rebuild. The rule itself is lib/postsVisibility.ts. +export type XPostsVisibility = "public" | "private"; + +export const X_POSTS_VISIBILITIES: readonly XPostsVisibility[] = ["public", "private"]; + +export function isXPostsVisibility(v: unknown): v is XPostsVisibility { + return v === "public" || v === "private"; +} + +// The `social` block of settings.json. Only X has settings today; the block is +// per platform so a second one does not need a second top-level key. export type XSocialSettings = { // Absent = the read-time default above. cookieSource?: XCookieSource; + // Absent = "public". + visibility?: XPostsVisibility; }; export type SocialSettings = { @@ -39,7 +56,8 @@ export type SocialSettings = { }; // Total over `unknown`, as every settings coercion is: anything that is not a -// known source reads as absent (the default), and unknown keys are dropped. +// known source or visibility reads as absent (the default), and unknown keys +// are dropped. export function sanitizeSocial(value: unknown): SocialSettings { const r = (value && typeof value === "object" && !Array.isArray(value) ? value @@ -48,7 +66,10 @@ export function sanitizeSocial(value: unknown): SocialSettings { ? r.x : {}) as Record<string, unknown>; return { - x: isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}, + x: { + ...(isXCookieSource(x.cookieSource) ? { cookieSource: x.cookieSource } : {}), + ...(isXPostsVisibility(x.visibility) ? { visibility: x.visibility } : {}), + }, }; } diff --git a/docker/publish-site.sh b/docker/publish-site.sh @@ -29,6 +29,20 @@ if [ -z "${SITE_ID}" ]; then fi export SITE_ID + +# A PRIVATE site (site.json `audience: "private"`) is never deployed, and the +# volume this script fills is what the `site` service serves: refused before +# the build, and again over the built corpus.json below (lib/builtExport.ts). +# Building it without publishing is `archilyzer build site <id>`. +SITE_JSON="${SITES_DIR:-${TRANSCRIPTS_DIR:-/data/transcripts}/sites}/${SITE_ID}/site.json" +refuse_private() { + echo "[publish-site] REFUSED — site '${SITE_ID}' is private (audience: private): it is built for reading on this machine and is never deployed. Nothing was published to ${SITE_OUT}. Build it without publishing: pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site ${SITE_ID}" >&2 + exit 1 +} +if grep -Eq '"audience"[[:space:]]*:[[:space:]]*"private"' "${SITE_JSON}" 2>/dev/null; then + refuse_private +fi + cd /repo echo "[publish-site] building '${SITE_ID}'" @@ -37,6 +51,9 @@ echo "[publish-site] building '${SITE_ID}'" pnpm --filter yt-dlp-transcript-common exec tsx bin/archilyzer.ts build site "${SITE_ID}" [ -d /repo/export/out ] || { echo "[publish-site] no export/out after build" >&2; exit 1; } +if grep -Eq '"audience"[[:space:]]*:[[:space:]]*"private"' /repo/export/out/corpus.json 2>/dev/null; then + refuse_private +fi echo "[publish-site] publishing -> ${SITE_OUT}" mkdir -p "${SITE_OUT}" diff --git a/export/app/offline/page.tsx b/export/app/offline/page.tsx @@ -1,6 +1,8 @@ import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { postsVisibleTo } from "yt-dlp-transcript-common/lib/postsVisibility"; import { currentSite } from "../lib/site"; import { OfflineManager, type OfflineChannel } from "../components/OfflineManager"; @@ -15,12 +17,19 @@ export const metadata: Metadata = { export default async function OfflinePage() { const site = currentSite(); const paths = getPaths(); - const channels: OfflineChannel[] = await Promise.all( - site.channels.map(async ({ slug }) => { - const config = await readChannelConfig(paths, slug).catch(() => null); - return { slug, name: config?.name ?? slug }; - }), + const settings = getSettings(); + // The members this build publishes: an X channel is left out of a public + // site while X posts are private (lib/postsVisibility.ts, the rule the + // index build and compose narrow the site's data by). + const members = await Promise.all( + site.channels.map(async ({ slug }) => ({ + slug, + config: await readChannelConfig(paths, slug).catch(() => null), + })), ); + const channels: OfflineChannel[] = members + .filter(({ config }) => postsVisibleTo(site, config, settings)) + .map(({ slug, config }) => ({ slug, name: config?.name ?? slug })); channels.sort((a, b) => a.name.localeCompare(b.name)); return (