From 1c25a0d6b8f9fbc7bc6803b26cf2dd089b9da3d7 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Mon, 20 Jul 2026 19:47:37 -0700 Subject: [PATCH] Add a guarded Standard.site Knowledge canary. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore protocol portability for one reviewed Knowledge entry while keeping bulk publication disabled and the dedicated publication out of discovery feeds. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- Dockerfile | 1 + knowledge/atproto-manifest.json | 22 ++ package.json | 1 + scripts/sync-knowledge-atproto.ts | 526 +++++++++++++++++++++++++++++ src/components/knowledge-entry.tsx | 10 +- src/index.tsx | 33 +- src/knowledge-atproto.ts | 46 +++ 7 files changed, 634 insertions(+), 5 deletions(-) create mode 100644 knowledge/atproto-manifest.json create mode 100644 scripts/sync-knowledge-atproto.ts create mode 100644 src/knowledge-atproto.ts diff --git a/Dockerfile b/Dockerfile index 80650d7..d2b2f2b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,7 @@ RUN npm install --omit=dev COPY src/ src/ COPY public/ public/ COPY knowledge/published/ knowledge/published/ +COPY knowledge/atproto-manifest.json knowledge/atproto-manifest.json COPY tsconfig.json ./ ENV PORT=8080 diff --git a/knowledge/atproto-manifest.json b/knowledge/atproto-manifest.json new file mode 100644 index 0000000..0afeede --- /dev/null +++ b/knowledge/atproto-manifest.json @@ -0,0 +1,22 @@ +{ + "version": 2, + "publication": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.publication/3mr4py6clps2f", + "cid": "bafyreigfq264qj2jsso63n5cgnn6kub4s6qkvxmgbwsbbqrbthxcrqoxzq", + "recordDigest": "sha256:db5de76b7c1d4e9a5cae22be69914ebfcaa1011cc3514838103d2ce6506eb675", + "showInDiscover": false, + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-21T02:07:32.234Z" + }, + "entries": { + "public-knowledge": { + "uri": "at://did:plc:gfrmhdmjvxn2sjedzboeudef/site.standard.document/3mr4py6yqpc2f", + "cid": "bafyreice22brw5q56zpsspyc66pvqdwaktnyu3slosv27z3faqshppbhtu", + "recordDigest": "sha256:0b781560b437fffaeb8485864415798c56ed987b1d021ef46b6f2cea69473099", + "reviewedContentDigest": "sha256:14a9732f1f041e263d5431e2b60133d9ee886cd54d26a9fb1e866fecc4620202", + "reviewReceiptDigest": "sha256:4093223e1ef8f719333f9b8687559d7c01d284c1812b637c0837858bb57df14f", + "pds": "https://enoki.us-east.host.bsky.network", + "syncedAt": "2026-07-21T02:07:32.234Z" + } + } +} diff --git a/package.json b/package.json index 46487a3..9a3f0f7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "knowledge:stage": "tsx scripts/stage-knowledge.ts", "knowledge:check": "tsx scripts/check-knowledge.ts", "knowledge:promote": "tsx scripts/promote-knowledge.ts", + "knowledge:sync": "tsx scripts/sync-knowledge-atproto.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/scripts/sync-knowledge-atproto.ts b/scripts/sync-knowledge-atproto.ts new file mode 100644 index 0000000..2bbab1e --- /dev/null +++ b/scripts/sync-knowledge-atproto.ts @@ -0,0 +1,526 @@ +import { createHash } from "node:crypto"; +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { TID } from "@atproto/common-web"; +import "../src/env.ts"; +import { + knowledgeContentDigest, + loadKnowledgeGraph, + type KnowledgeEntry, +} from "../src/knowledge.ts"; +import type { KnowledgeAtprotoManifest } from "../src/knowledge-atproto.ts"; +import { loadKnowledgePolicy, scanKnowledgeDraft } from "./knowledge-policy.ts"; + +const CAMERON_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef"; +const PUBLICATION_COLLECTION = "site.standard.publication"; +const DOCUMENT_COLLECTION = "site.standard.document"; +const PUBLICATION_URL = "https://cameron.stream/knowledge"; +const PUBLICATION_NAME = "Knowledge"; +const PUBLICATION_DESCRIPTION = + "Linked technical notes, subject maps, lessons, and dated synthesis from Cameron."; +const MANIFEST_PATH = resolve(process.cwd(), "knowledge/atproto-manifest.json"); + +interface BlobRef { + $type: "blob"; + ref: { $link: string }; + mimeType: string; + size: number; +} + +interface PublicationRecord { + $type: typeof PUBLICATION_COLLECTION; + url: string; + name: string; + description: string; + icon: BlobRef; + basicTheme: { + $type: "site.standard.theme.basic"; + background: Color; + foreground: Color; + accent: Color; + accentForeground: Color; + }; + preferences: { showInDiscover: false }; +} + +interface Color { + $type: "site.standard.theme.color#rgb"; + r: number; + g: number; + b: number; +} + +interface StandardDocumentRecord { + $type: typeof DOCUMENT_COLLECTION; + site: string; + title: string; + description: string; + publishedAt: string; + updatedAt: string; + path: string; + tags: string[]; + textContent: string; + content: { + $type: "site.standard.content.markdown"; + text: string; + version: "1.0"; + }; +} + +interface RemoteRecord { + uri: string; + cid: string; + value: T; +} + +type PlanAction = "create" | "update" | "unchanged" | "adopt" | "conflict"; + +interface RecordPlan { + collection: string; + rkey: string; + uri: string; + action: PlanAction; + reason: string; + record: T; + recordDigest: string; + remoteCid?: string; +} + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`) + .join(",")}}`; + } + return JSON.stringify(value); +} + +function digestRecord(record: unknown): string { + return `sha256:${createHash("sha256").update(canonicalJson(record)).digest("hex")}`; +} + +function loadManifest(): KnowledgeAtprotoManifest { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as KnowledgeAtprotoManifest; + if (manifest.version !== 2) throw new Error("Unsupported Knowledge ATProto manifest"); + if (manifest.publication && manifest.publication.showInDiscover !== false) { + throw new Error("Refusing a Knowledge manifest without explicit showInDiscover=false"); + } + return manifest; +} + +function writeManifest(manifest: KnowledgeAtprotoManifest): void { + const temporary = `${MANIFEST_PATH}.tmp`; + writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); + renameSync(temporary, MANIFEST_PATH); +} + +function desiredPublication(): PublicationRecord { + const color = (r: number, g: number, b: number): Color => ({ + $type: "site.standard.theme.color#rgb", + r, + g, + b, + }); + return { + $type: PUBLICATION_COLLECTION, + url: PUBLICATION_URL, + name: PUBLICATION_NAME, + description: PUBLICATION_DESCRIPTION, + icon: { + $type: "blob", + ref: { $link: "bafkreibo3puyp2y32k33cp57em6ihxkoqgxfvrvbrmejnkdsjia5su3imi" }, + mimeType: "image/webp", + size: 3680, + }, + basicTheme: { + $type: "site.standard.theme.basic", + background: color(15, 15, 15), + foreground: color(232, 228, 234), + accent: color(180, 151, 191), + accentForeground: color(255, 255, 255), + }, + preferences: { showInDiscover: false }, + }; +} + +function publicMarkdown(entry: KnowledgeEntry): string { + const absoluteLinks = entry.body.replace( + /\]\(\/knowledge\/([a-z0-9-]+)([^)]*)\)/g, + "](https://cameron.stream/knowledge/$1$2)", + ); + const sources = entry.sources.length === 0 + ? "" + : `\n\n## Sources\n\n${entry.sources + .map((source) => `- [${source.title}](${source.url})`) + .join("\n")}`; + return `${absoluteLinks}${sources}`; +} + +function stripMarkdown(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, "") + .replace(/`([^`]+)`/g, "$1") + .replace(/!\[[^\]]*\]\([^)]+\)/g, "") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, "") + .replace(/^#{1,6}\s+/gm, "") + .replace(/^\s*>\s?/gm, "") + .replace(/^\s*[-*+]\s+/gm, "") + .replace(/^\s*\d+\.\s+/gm, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/__([^_]+)__/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function desiredDocument(entry: KnowledgeEntry, publicationUri: string): StandardDocumentRecord { + const markdown = publicMarkdown(entry); + const record: StandardDocumentRecord = { + $type: DOCUMENT_COLLECTION, + site: publicationUri, + title: entry.title, + description: entry.summary, + publishedAt: (entry.publishedAt ?? entry.updated).toISOString(), + updatedAt: entry.updated.toISOString(), + path: `/${entry.slug}`, + tags: [...new Set(["knowledge", entry.kind, ...entry.topics])], + textContent: stripMarkdown(markdown).slice(0, 100_000), + content: { + $type: "site.standard.content.markdown", + text: markdown, + version: "1.0", + }, + }; + const bytes = Buffer.byteLength(JSON.stringify(record)); + if (bytes > 95_000) throw new Error(`${entry.slug}: Standard.site record is too large (${bytes} bytes)`); + return record; +} + +async function resolvePds(): Promise { + const response = await fetch(`https://plc.directory/${CAMERON_DID}`); + if (!response.ok) throw new Error(`Could not resolve Cameron DID: ${response.status}`); + const document = await response.json() as { + service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }>; + }; + const pds = document.service?.find((service) => + service.id === "#atproto_pds" || service.type === "AtprotoPersonalDataServer" + )?.serviceEndpoint; + if (!pds) throw new Error("Cameron DID has no ATProto PDS endpoint"); + return pds.replace(/\/$/, ""); +} + +async function listRecords(pds: string, collection: string): Promise>> { + const records: Array> = []; + let cursor: string | undefined; + do { + const query = new URLSearchParams({ + repo: CAMERON_DID, + collection, + limit: "100", + ...(cursor ? { cursor } : {}), + }); + const response = await fetch(`${pds}/xrpc/com.atproto.repo.listRecords?${query}`); + if (!response.ok) throw new Error(`listRecords ${collection} failed: ${response.status}`); + const page = await response.json() as { records: Array>; cursor?: string }; + records.push(...page.records); + cursor = page.cursor; + } while (cursor); + return records; +} + +async function getRecord( + pds: string, + collection: string, + rkey: string, +): Promise | null> { + const query = new URLSearchParams({ repo: CAMERON_DID, collection, rkey }); + const response = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${query}`); + if (response.status === 400) { + const body = await response.json().catch(() => ({})) as { error?: string }; + if (body.error === "RecordNotFound") return null; + } + if (!response.ok) throw new Error(`getRecord ${collection}/${rkey} failed: ${response.status}`); + return await response.json() as RemoteRecord; +} + +function rkeyFromUri(uri: string): string { + return uri.slice(uri.lastIndexOf("/") + 1); +} + +function recordUri(collection: string, rkey: string): string { + return `at://${CAMERON_DID}/${collection}/${rkey}`; +} + +function classify(options: { + collection: string; + desired: T; + manifestUri?: string; + manifestCid?: string; + discovered: Array>; + createRkey?: string; +}): RecordPlan { + const { collection, desired, manifestUri, manifestCid, discovered } = options; + if (discovered.length > 1) { + return { + collection, + rkey: "conflict", + uri: "conflict", + action: "conflict", + reason: "multiple remote records match the same canonical object", + record: desired, + recordDigest: digestRecord(desired), + }; + } + const remote = discovered[0]; + const desiredDigest = digestRecord(desired); + if (!remote) { + if (manifestUri) { + return { + collection, + rkey: rkeyFromUri(manifestUri), + uri: manifestUri, + action: "conflict", + reason: "manifest expects a remote record that is missing", + record: desired, + recordDigest: desiredDigest, + }; + } + const rkey = options.createRkey ?? TID.nextStr(); + return { + collection, + rkey, + uri: recordUri(collection, rkey), + action: "create", + reason: "no matching remote record exists", + record: desired, + recordDigest: desiredDigest, + }; + } + const remoteDigest = digestRecord(remote.value); + if (remoteDigest === desiredDigest) { + return { + collection, + rkey: rkeyFromUri(remote.uri), + uri: remote.uri, + action: manifestUri ? "unchanged" : "adopt", + reason: manifestUri ? "remote record matches the manifest and local source" : "matching remote record can be adopted", + record: desired, + recordDigest: desiredDigest, + remoteCid: remote.cid, + }; + } + if (!manifestUri || manifestUri !== remote.uri || manifestCid !== remote.cid) { + return { + collection, + rkey: rkeyFromUri(remote.uri), + uri: remote.uri, + action: "conflict", + reason: "remote record differs and is not owned by the current manifest CID", + record: desired, + recordDigest: desiredDigest, + remoteCid: remote.cid, + }; + } + return { + collection, + rkey: rkeyFromUri(remote.uri), + uri: remote.uri, + action: "update", + reason: "approved local source changed while the remote CID still matches the manifest", + record: desired, + recordDigest: desiredDigest, + remoteCid: remote.cid, + }; +} + +async function createSession(pds: string): Promise<{ accessJwt: string; did: string }> { + const password = process.env.CAMERON_BSKY_APP_PASSWORD; + if (!password) throw new Error("CAMERON_BSKY_APP_PASSWORD is required for --apply"); + const response = await fetch(`${pds}/xrpc/com.atproto.server.createSession`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ identifier: CAMERON_DID, password }), + }); + if (!response.ok) throw new Error(`createSession failed: ${response.status} ${await response.text()}`); + const session = await response.json() as { accessJwt: string; did: string }; + if (session.did !== CAMERON_DID) { + throw new Error(`Refusing wrong identity: authenticated ${session.did}, expected ${CAMERON_DID}`); + } + return session; +} + +function toWrite(plan: RecordPlan): Record | undefined { + if (plan.action === "create") { + return { + $type: "com.atproto.repo.applyWrites#create", + collection: plan.collection, + rkey: plan.rkey, + value: plan.record, + }; + } + if (plan.action === "update") { + return { + $type: "com.atproto.repo.applyWrites#update", + collection: plan.collection, + rkey: plan.rkey, + value: plan.record, + swapRecord: plan.remoteCid, + }; + } + return undefined; +} + +async function applyWrites( + pds: string, + accessJwt: string, + plans: Array>, +): Promise { + const writes = plans.flatMap((plan) => { + const write = toWrite(plan); + return write ? [write] : []; + }); + if (writes.length === 0) return { unchanged: true }; + const response = await fetch(`${pds}/xrpc/com.atproto.repo.applyWrites`, { + method: "POST", + headers: { Authorization: `Bearer ${accessJwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ repo: CAMERON_DID, writes }), + }); + if (!response.ok) throw new Error(`applyWrites failed: ${response.status} ${await response.text()}`); + return await response.json(); +} + +async function main(): Promise { + const apply = process.argv.includes("--apply"); + const includeRecords = process.argv.includes("--json"); + const slugIndex = process.argv.indexOf("--slug"); + const slug = slugIndex >= 0 ? process.argv[slugIndex + 1] : undefined; + if (!slug) throw new Error("A single --slug is required; bulk Knowledge publication is intentionally disabled"); + + const graph = await loadKnowledgeGraph(); + if (graph.errors.length > 0) throw new Error(`knowledge graph errors: ${graph.errors.join("; ")}`); + const entry = graph.bySlug.get(slug); + if (!entry || entry.draft) throw new Error(`approved Knowledge entry not found: ${slug}`); + + const policy = loadKnowledgePolicy(); + const blocking = scanKnowledgeDraft(entry.body, policy).filter((finding) => finding.severity === "block"); + if (blocking.length > 0) throw new Error(`${slug}: blocking policy findings: ${JSON.stringify(blocking)}`); + if (entry.kind === "person" && !policy.allowedPeopleSlugs.includes(entry.slug)) { + throw new Error(`${slug}: person entry is not explicitly allowlisted`); + } + if (knowledgeContentDigest(entry) !== entry.reviewedContentDigest) { + throw new Error(`${slug}: approved source no longer matches its reviewed digest`); + } + + const manifest = loadManifest(); + const pds = await resolvePds(); + const publications = await listRecords(pds, PUBLICATION_COLLECTION); + const publicationMatches = manifest.publication + ? (await Promise.all([ + getRecord(pds, PUBLICATION_COLLECTION, rkeyFromUri(manifest.publication.uri)), + ])).filter((record): record is RemoteRecord => Boolean(record)) + : publications.filter((record) => record.value.url === PUBLICATION_URL); + + const publicationRecord = desiredPublication(); + const publicationPlan = classify({ + collection: PUBLICATION_COLLECTION, + desired: publicationRecord, + manifestUri: manifest.publication?.uri, + manifestCid: manifest.publication?.cid, + discovered: publicationMatches, + }); + if (publicationRecord.preferences.showInDiscover !== false) { + throw new Error("Knowledge publication must explicitly set showInDiscover=false"); + } + + const documentRecord = desiredDocument(entry, publicationPlan.uri); + const manifestEntry = manifest.entries[slug]; + const documents = manifestEntry + ? (await Promise.all([ + getRecord(pds, DOCUMENT_COLLECTION, rkeyFromUri(manifestEntry.uri)), + ])).filter((record): record is RemoteRecord => Boolean(record)) + : (await listRecords(pds, DOCUMENT_COLLECTION)).filter( + (record) => record.value.site === publicationPlan.uri && record.value.path === `/${slug}`, + ); + const documentPlan = classify({ + collection: DOCUMENT_COLLECTION, + desired: documentRecord, + manifestUri: manifestEntry?.uri, + manifestCid: manifestEntry?.cid, + discovered: documents, + }); + const plans: Array> = [publicationPlan, documentPlan]; + + const conflicts = plans.filter((plan) => plan.action === "conflict"); + const report = { + mode: apply ? "apply" : "dry-run", + did: CAMERON_DID, + pds, + discoveryInvariant: { + field: "site.standard.publication.preferences.showInDiscover", + value: false, + scope: "dedicated Knowledge publication", + }, + publication: includeRecords ? publicationPlan : { ...publicationPlan, record: undefined }, + document: includeRecords ? documentPlan : { ...documentPlan, record: undefined }, + }; + if (!apply) { + console.log(JSON.stringify(report, null, 2)); + return; + } + if (conflicts.length > 0) throw new Error(`refusing conflicts: ${JSON.stringify(conflicts)}`); + + const session = await createSession(pds); + const writeReceipt = await applyWrites(pds, session.accessJwt, plans); + const verifiedPublication = await getRecord(pds, PUBLICATION_COLLECTION, publicationPlan.rkey); + const verifiedDocument = await getRecord(pds, DOCUMENT_COLLECTION, documentPlan.rkey); + if (!verifiedPublication || digestRecord(verifiedPublication.value) !== publicationPlan.recordDigest) { + throw new Error("post-write Knowledge publication verification failed"); + } + if (verifiedPublication.value.preferences?.showInDiscover !== false) { + throw new Error("post-write discovery suppression verification failed"); + } + if (!verifiedDocument || digestRecord(verifiedDocument.value) !== documentPlan.recordDigest) { + throw new Error("post-write Knowledge document verification failed"); + } + if (verifiedDocument.value.site !== verifiedPublication.uri || verifiedDocument.value.path !== `/${slug}`) { + throw new Error("post-write document publication/path verification failed"); + } + + const syncedAt = new Date().toISOString(); + manifest.publication = { + uri: verifiedPublication.uri, + cid: verifiedPublication.cid, + recordDigest: publicationPlan.recordDigest, + showInDiscover: false, + pds, + syncedAt, + }; + manifest.entries[slug] = { + uri: verifiedDocument.uri, + cid: verifiedDocument.cid, + recordDigest: documentPlan.recordDigest, + reviewedContentDigest: entry.reviewedContentDigest!, + reviewReceiptDigest: entry.reviewReceiptDigest!, + pds, + syncedAt, + }; + writeManifest(manifest); + console.log(JSON.stringify({ + ...report, + receipt: { + write: writeReceipt, + publication: { uri: verifiedPublication.uri, cid: verifiedPublication.cid, showInDiscover: false }, + document: { uri: verifiedDocument.uri, cid: verifiedDocument.cid, canonicalUrl: `${PUBLICATION_URL}/${slug}` }, + manifest: MANIFEST_PATH, + }, + }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +}); diff --git a/src/components/knowledge-entry.tsx b/src/components/knowledge-entry.tsx index 24153be..022a3ce 100644 --- a/src/components/knowledge-entry.tsx +++ b/src/components/knowledge-entry.tsx @@ -16,7 +16,13 @@ export async function getKnowledgeEntry(slug: string): Promise {noIndex && } + {recordLinks.publication && } + {recordLinks.document && } {pageTitle} @@ -90,6 +98,14 @@ function Shell({ app.get("/healthz", (c) => c.json({ status: "ok" })); +app.get("/.well-known/site.standard.publication", (c) => { + const publicationUri = getKnowledgePublicationUri(); + if (!publicationUri) return c.notFound(); + return c.text(publicationUri, 200, { + "Cache-Control": "public, max-age=300", + }); +}); + app.get("/", async (c) => { const stream = renderToReadableStream( @@ -178,8 +194,13 @@ app.get("/library", (c) => c.redirect("/knowledge#links", 301)); app.get("/knowledge", async (c) => { const includeDrafts = process.env.KNOWLEDGE_INCLUDE_DRAFTS === "1"; + const publicationUri = getKnowledgePublicationUri(); const stream = renderToReadableStream( - + ); @@ -198,10 +219,16 @@ app.get("/knowledge/:slug", async (c) => { const slug = c.req.param("slug"); const entry = await getKnowledgeEntry(slug); if (!entry) return c.notFound(); + const publicationUri = getKnowledgePublicationUri(); + const documentUri = getKnowledgeDocumentUri(slug); const stream = renderToReadableStream( - - + + ); return c.body(stream, { diff --git a/src/knowledge-atproto.ts b/src/knowledge-atproto.ts new file mode 100644 index 0000000..8250bda --- /dev/null +++ b/src/knowledge-atproto.ts @@ -0,0 +1,46 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +export interface KnowledgeAtprotoManifestEntry { + uri: string; + cid: string; + recordDigest: string; + reviewedContentDigest: string; + reviewReceiptDigest: string; + pds: string; + syncedAt: string; +} + +export interface KnowledgeAtprotoManifest { + version: 2; + publication: null | { + uri: string; + cid: string; + recordDigest: string; + showInDiscover: false; + pds: string; + syncedAt: string; + }; + entries: Record; +} + +const MANIFEST_PATH = resolve(process.cwd(), "knowledge/atproto-manifest.json"); + +export function loadKnowledgeAtprotoManifest(): KnowledgeAtprotoManifest { + const manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")) as KnowledgeAtprotoManifest; + if (manifest.version !== 2) { + throw new Error(`Unsupported Knowledge ATProto manifest version: ${String(manifest.version)}`); + } + if (manifest.publication && manifest.publication.showInDiscover !== false) { + throw new Error("Knowledge publication must explicitly remain out of discovery feeds"); + } + return manifest; +} + +export function getKnowledgePublicationUri(): string | undefined { + return loadKnowledgeAtprotoManifest().publication?.uri; +} + +export function getKnowledgeDocumentUri(slug: string): string | undefined { + return loadKnowledgeAtprotoManifest().entries[slug]?.uri; +} -- 2.51.2