From 9b712e724f4735122f3fa62e71fd8584c2a138f8 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 00:40:25 -0400 Subject: [PATCH 1/6] feat(blog): hydrate posts from Tinyland broker stream --- ...and-static-post-pulse-ingest-2026-05-10.md | 25 +- package-lock.json | 2 + package.json | 2 + .../pulse-core/src/schema/ap-stream-demo.ts | 2 +- packages/pulse-core/test/schema.test.ts | 6 +- scripts/ingest-tinyland-posts.mts | 7 +- .../pulse/PulseApStreamDemoPanel.test.ts | 2 +- src/lib/pulse/apStreamDemo.test.ts | 2 +- src/lib/pulse/apStreamDemo.ts | 2 +- src/lib/tinyland-post-snapshot.test.ts | 4 +- src/lib/tinyland/blogBrokerStream.test.ts | 158 +++++++++ src/lib/tinyland/blogBrokerStream.ts | 305 ++++++++++++++++++ src/lib/tinyland/marked.d.ts | 5 + src/lib/tinyland/runtimeMarkdown.ts | 25 ++ src/routes/blog/+page.svelte | 78 ++++- src/routes/blog/[slug]/+page.svelte | 192 ++++++++--- src/routes/blog/[slug]/+page.ts | 26 +- 17 files changed, 779 insertions(+), 64 deletions(-) create mode 100644 src/lib/tinyland/blogBrokerStream.test.ts create mode 100644 src/lib/tinyland/blogBrokerStream.ts create mode 100644 src/lib/tinyland/marked.d.ts create mode 100644 src/lib/tinyland/runtimeMarkdown.ts diff --git a/docs/tinyland-static-post-pulse-ingest-2026-05-10.md b/docs/tinyland-static-post-pulse-ingest-2026-05-10.md index 1af2bb3..5a18062 100644 --- a/docs/tinyland-static-post-pulse-ingest-2026-05-10.md +++ b/docs/tinyland-static-post-pulse-ingest-2026-05-10.md @@ -3,8 +3,13 @@ Date: 2026-05-10 This repo is the static consumer for `transscendsurvival.org`. Tinyland owns the -reviewed source projection; this site keeps rendering ordinary frontmatter posts -and a checked-in Pulse snapshot. +reviewed source projection. + +2026-05-19 correction: this checked-in ingest path is fallback and migration +evidence only. The intended Cloudflare Pages display path is runtime broker +fetch from `hub.tinyland.dev`, with Tinyland-managed greymatter as the source of +truth. Checked-in snapshots must not be treated as the live blog federation +mechanism. ## Checked-In Inputs @@ -20,8 +25,13 @@ Pulse projection snapshot: static/data/pulse/public-snapshot.v1.json ``` -Both are copied from Tinyland reviewed static artifacts. They are not fetched -from a live broker at runtime. +Both are copied from Tinyland reviewed static artifacts. They remain useful as +first-paint and regression fixtures, but canonical blog display now hydrates +from: + +```text +https://hub.tinyland.dev/projections/jesssullivan-github-io/blog/broker-stream.v1.json +``` ## Post Ingest @@ -85,13 +95,14 @@ index and validating Pulse. Allowed: -- checked-in static snapshots; -- ordinary Markdown/frontmatter posts in `src/posts`; +- checked-in static snapshots as fallback/regression fixtures; +- ordinary Markdown/frontmatter posts in `src/posts` for legacy/static + first-paint content; +- runtime display fetches from the public `hub.tinyland.dev` broker stream; - the existing `PublicPulseSnapshot` validator and `/pulse` renderer. Blocked: -- live broker fetches during SvelteKit render; - mutations back into Tinyland; - ActivityPub delivery, inbox, follower, retry, tombstone, or moderation claims; - private storage refs, exact location payloads, credentials, or payment data. diff --git a/package-lock.json b/package-lock.json index 4c870ba..2e4dcce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,9 @@ "@skeletonlabs/skeleton": "^4.15.2", "@skeletonlabs/skeleton-svelte": "^4.15.2", "@tummycrypt/tinyvectors": "^0.2.3", + "dompurify": "^3.3.3", "flexsearch": "^0.8.212", + "marked": "^4.3.0", "mdsvex": "^0.12.7", "shiki": "^4.0.2", "tailwindcss": "^4.2.2" diff --git a/package.json b/package.json index 9e03afe..0f93e6e 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,9 @@ "@skeletonlabs/skeleton": "^4.15.2", "@skeletonlabs/skeleton-svelte": "^4.15.2", "@tummycrypt/tinyvectors": "^0.2.3", + "dompurify": "^3.3.3", "flexsearch": "^0.8.212", + "marked": "^4.3.0", "mdsvex": "^0.12.7", "shiki": "^4.0.2", "tailwindcss": "^4.2.2" diff --git a/packages/pulse-core/src/schema/ap-stream-demo.ts b/packages/pulse-core/src/schema/ap-stream-demo.ts index b6973f6..d65366e 100644 --- a/packages/pulse-core/src/schema/ap-stream-demo.ts +++ b/packages/pulse-core/src/schema/ap-stream-demo.ts @@ -38,7 +38,7 @@ export const PulseApStreamDemoSchema = z schemaVersion: z.literal(PULSE_AP_STREAM_DEMO_SCHEMA_VERSION), generatedAt: IsoTimestampSchema, sourceAuthority: z.literal('tinyland.dev'), - sourceAuthorityUrl: z.literal('https://tinyland.dev'), + sourceAuthorityUrl: z.literal('https://hub.tinyland.dev'), sourceSnapshotId: z.string().min(1), contentHash: z.string().regex(/^sha256:[0-9a-f]{64}$/), policyVersion: z.string().min(1), diff --git a/packages/pulse-core/test/schema.test.ts b/packages/pulse-core/test/schema.test.ts index e05c2df..6c76b9c 100644 --- a/packages/pulse-core/test/schema.test.ts +++ b/packages/pulse-core/test/schema.test.ts @@ -155,7 +155,7 @@ describe('schema/ap-stream-demo', () => { schemaVersion: PULSE_AP_STREAM_DEMO_SCHEMA_VERSION, generatedAt: '2026-05-10T13:00:00.000Z', sourceAuthority: 'tinyland.dev', - sourceAuthorityUrl: 'https://tinyland.dev', + sourceAuthorityUrl: 'https://hub.tinyland.dev', sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10', contentHash: `sha256:${'b'.repeat(64)}`, policyVersion: PUBLIC_SNAPSHOT_POLICY_VERSION, @@ -166,11 +166,11 @@ describe('schema/ap-stream-demo', () => { spokeRef: 'jesssullivan-github-io', spokeTarget: 'transscendsurvival.org', routePath: '/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', - publicUrl: 'https://tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', + publicUrl: 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', itemCount: 1, orderedItems: [ { - id: 'https://tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json#note-1', + id: 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json#note-1', type: 'Note', published: '2026-05-10T12:30:00.000Z', summary: 'hello', diff --git a/scripts/ingest-tinyland-posts.mts b/scripts/ingest-tinyland-posts.mts index 7067e54..68158ff 100644 --- a/scripts/ingest-tinyland-posts.mts +++ b/scripts/ingest-tinyland-posts.mts @@ -1,7 +1,8 @@ #!/usr/bin/env tsx -// Materializes reviewed tinyland.dev post projections into the existing -// src/posts/*.md frontmatter flow. The static site remains a read-only -// consumer: this script only reads a checked-in snapshot. +// Fallback/migration only. The canonical blog display path is the CF Pages +// runtime fetch from the public Tinyland broker stream, not checked-in posts. +// This script materializes reviewed snapshot evidence into the legacy +// src/posts/*.md frontmatter flow when explicitly invoked. import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; diff --git a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts index 0dbac1c..d3faa85 100644 --- a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts +++ b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts @@ -10,7 +10,7 @@ const readyState: PulseApStreamDemoPanelState = { schemaVersion: 'tinyland.pulse.ap-stream-demo.v1', generatedAt: '2026-05-10T13:00:00.000Z', sourceAuthority: 'tinyland.dev', - sourceAuthorityUrl: 'https://tinyland.dev', + sourceAuthorityUrl: 'https://hub.tinyland.dev', sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10', contentHash: 'sha256:fc3b04ec97946d6777e5245040b09a3ead296a9bf4614d0fea7df2d3cfb2ccb7', policyVersion: 'm1-2026-04-27', diff --git a/src/lib/pulse/apStreamDemo.test.ts b/src/lib/pulse/apStreamDemo.test.ts index 458ed1b..1c130c8 100644 --- a/src/lib/pulse/apStreamDemo.test.ts +++ b/src/lib/pulse/apStreamDemo.test.ts @@ -10,7 +10,7 @@ const validDemo: PulseApStreamDemo = { schemaVersion: 'tinyland.pulse.ap-stream-demo.v1', generatedAt: '2026-05-10T13:00:00.000Z', sourceAuthority: 'tinyland.dev', - sourceAuthorityUrl: 'https://tinyland.dev', + sourceAuthorityUrl: 'https://hub.tinyland.dev', sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10', contentHash: 'sha256:fc3b04ec97946d6777e5245040b09a3ead296a9bf4614d0fea7df2d3cfb2ccb7', policyVersion: 'm1-2026-04-27', diff --git a/src/lib/pulse/apStreamDemo.ts b/src/lib/pulse/apStreamDemo.ts index 31c1330..5675480 100644 --- a/src/lib/pulse/apStreamDemo.ts +++ b/src/lib/pulse/apStreamDemo.ts @@ -1,7 +1,7 @@ import { PulseApStreamDemoSchema, type PulseApStreamDemo } from '@blog/pulse-core/schema'; export const TINYLAND_PULSE_AP_STREAM_DEMO_URL = - 'https://tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json'; + 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json'; export type PulseApStreamFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; diff --git a/src/lib/tinyland-post-snapshot.test.ts b/src/lib/tinyland-post-snapshot.test.ts index d52a3f5..9d0661d 100644 --- a/src/lib/tinyland-post-snapshot.test.ts +++ b/src/lib/tinyland-post-snapshot.test.ts @@ -43,7 +43,7 @@ function snapshotHash(snapshot: TinylandPostSnapshot): string { } describe('Tinyland post projection snapshot', () => { - it('keeps transscendsurvival.org as a checked-in static consumer', () => { + it('keeps checked-in post snapshots fallback-only and non-authoritative', () => { const snapshot = readJson('static/data/tinyland/posts/public-snapshot.v1.json'); expect(snapshot.schemaVersion).toBe('tinyland.static-spoke.snapshot.v1'); @@ -60,7 +60,7 @@ describe('Tinyland post projection snapshot', () => { ); }); - it('materializes reviewed posts into the extant frontmatter flow', () => { + it('materializes reviewed snapshot evidence into the legacy frontmatter flow', () => { const snapshot = readJson('static/data/tinyland/posts/public-snapshot.v1.json'); for (const post of snapshot.posts) { diff --git a/src/lib/tinyland/blogBrokerStream.test.ts b/src/lib/tinyland/blogBrokerStream.test.ts new file mode 100644 index 0000000..37f4950 --- /dev/null +++ b/src/lib/tinyland/blogBrokerStream.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + TINYLAND_BLOG_BROKER_STREAM_URL, + findTinylandBlogBrokerPost, + loadTinylandBlogBrokerStream, + tinylandBlogBrokerStreamToPosts, + type TinylandBlogBrokerFetch, + type TinylandBlogBrokerStream, +} from './blogBrokerStream'; + +const validStream: TinylandBlogBrokerStream = { + schemaVersion: 'tinyland.blog.broker-stream.v1', + generatedAt: '2026-05-19T04:00:00.000Z', + sourceAuthority: 'tinyland.dev', + contentAuthority: 'tinyland.dev', + spokeRef: 'jesssullivan-github-io', + spokeTarget: 'transscendsurvival.org', + routePath: '/projections/jesssullivan-github-io/blog/broker-stream.v1.json', + publicUrl: TINYLAND_BLOG_BROKER_STREAM_URL, + brokerOrigin: 'https://hub.tinyland.dev', + streamStatus: 'dynamic-hub-managed-greymatter', + managementStatus: 'hub-managed-greymatter', + runtimeBrokerFetch: true, + publicFediverseDelivery: false, + activityPubStatus: 'broker-display-stream-not-public-fediverse-delivery', + contentHash: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', + counts: { + reviewedStreamPosts: 1, + }, + consumerContract: { + mode: 'cf-pages-runtime-broker-fetch', + mutationAuthority: 'tinyland.dev', + contentAuthority: 'tinyland.dev', + checkedInPostPayloads: false, + spokeMutationApi: false, + spokeActivityPubWorker: false, + }, + policy: { + contentTransport: 'dynamic-broker-stream', + contentMarkdownIncluded: true, + draftContentIncluded: false, + unreviewedContentIncluded: false, + publicFediverseDelivery: false, + }, + posts: [ + { + type: 'Article', + id: 'https://hub.tinyland.dev/projections/jesssullivan-github-io/ap/objects/post/example', + slug: 'example', + title: 'Example', + date: '2026-05-19', + publishedAt: '2026-05-19T00:00:00.000Z', + updatedAt: '2026-05-19T00:00:00.000Z', + description: 'Brokered example', + category: 'software', + tags: ['tinyland', 'broker'], + url: 'https://transscendsurvival.org/blog/example', + sourceRecord: 'content/users/jesssullivan/blog/example.md', + sourceHash: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + contentHash: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc', + reviewStatus: 'operator-reviewed-source-public', + frontmatter: { title: 'Example' }, + contentMarkdown: '## Hello\n\nBrokered post body.', + contentFormat: 'text/markdown', + publicFediverseDelivery: false, + }, + ], + projectionTombstones: [], +}; + +const jsonResponse = (body: unknown, init: ResponseInit = {}) => + new Response(JSON.stringify(body), { + status: init.status ?? 200, + statusText: init.statusText, + headers: { 'Content-Type': 'application/json' }, + }); + +describe('loadTinylandBlogBrokerStream', () => { + it('fetches the hub broker stream without falling back to checked-in post snapshots', async () => { + const fetchMock = vi.fn(async () => jsonResponse(validStream)); + + await expect(loadTinylandBlogBrokerStream(fetchMock)).resolves.toEqual(validStream); + expect(fetchMock).toHaveBeenCalledWith( + TINYLAND_BLOG_BROKER_STREAM_URL, + expect.objectContaining({ + headers: { Accept: 'application/json' }, + cache: 'no-store', + }), + ); + expect(fetchMock.mock.calls.flat().join(' ')).not.toContain('/data/tinyland/posts/public-snapshot.v1.json'); + }); + + it('rejects streams that try to behave like checked-in materialization', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + ...validStream, + consumerContract: { + ...validStream.consumerContract, + checkedInPostPayloads: true, + }, + }), + ); + + await expect(loadTinylandBlogBrokerStream(fetchMock)).rejects.toThrow( + 'checkedInPostPayloads must be false', + ); + }); + + it('rejects public Fediverse delivery claims on the display stream', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + ...validStream, + publicFediverseDelivery: true, + }), + ); + + await expect(loadTinylandBlogBrokerStream(fetchMock)).rejects.toThrow( + 'publicFediverseDelivery must be false', + ); + }); + + it('rejects unreviewed broker posts', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + ...validStream, + posts: validStream.posts.map((post) => ({ + ...post, + reviewStatus: 'draft-held-back', + })), + }), + ); + + await expect(loadTinylandBlogBrokerStream(fetchMock)).rejects.toThrow( + 'reviewStatus must be operator-reviewed-source-public', + ); + }); + + it('maps broker posts into existing BlogCard post shape', () => { + const posts = tinylandBlogBrokerStreamToPosts(validStream); + + expect(posts).toEqual([ + expect.objectContaining({ + title: 'Example', + slug: 'example', + category: 'software', + tags: ['tinyland', 'broker'], + content: '## Hello\n\nBrokered post body.', + published: true, + }), + ]); + expect(posts[0].reading_time).toBeGreaterThanOrEqual(1); + }); + + it('finds a stream post by slug for canonical route hydration', () => { + expect(findTinylandBlogBrokerPost(validStream, 'example')?.title).toBe('Example'); + expect(findTinylandBlogBrokerPost(validStream, 'missing')).toBeNull(); + }); +}); diff --git a/src/lib/tinyland/blogBrokerStream.ts b/src/lib/tinyland/blogBrokerStream.ts new file mode 100644 index 0000000..81f49f6 --- /dev/null +++ b/src/lib/tinyland/blogBrokerStream.ts @@ -0,0 +1,305 @@ +import { POST_CATEGORIES, type Post, type PostCategory } from '$lib/types'; + +export const TINYLAND_BLOG_BROKER_STREAM_URL = + 'https://hub.tinyland.dev/projections/jesssullivan-github-io/blog/broker-stream.v1.json'; +export const TINYLAND_BLOG_BROKER_STREAM_SCHEMA_VERSION = 'tinyland.blog.broker-stream.v1'; + +export type TinylandBlogBrokerFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; + +export interface TinylandBlogBrokerFetchOptions { + readonly endpoint?: string; + readonly signal?: AbortSignal; +} + +export interface TinylandBlogBrokerPost { + readonly type: 'Article'; + readonly id: string; + readonly slug: string; + readonly title: string; + readonly date: string; + readonly publishedAt: string; + readonly updatedAt: string; + readonly description: string; + readonly category: string; + readonly tags: readonly unknown[]; + readonly featureImage?: string; + readonly url: string; + readonly sourceRecord: string; + readonly sourceHash: string; + readonly contentHash: string; + readonly reviewStatus: string; + readonly frontmatter: Record; + readonly contentMarkdown: string; + readonly contentFormat: 'text/markdown'; + readonly publicFediverseDelivery: false; +} + +export interface TinylandBlogBrokerStream { + readonly schemaVersion: typeof TINYLAND_BLOG_BROKER_STREAM_SCHEMA_VERSION; + readonly generatedAt: string; + readonly sourceAuthority: 'tinyland.dev'; + readonly contentAuthority: 'tinyland.dev'; + readonly spokeRef: 'jesssullivan-github-io'; + readonly spokeTarget: 'transscendsurvival.org'; + readonly routePath: '/projections/jesssullivan-github-io/blog/broker-stream.v1.json'; + readonly publicUrl: string; + readonly brokerOrigin: string; + readonly streamStatus: 'dynamic-hub-managed-greymatter'; + readonly managementStatus: 'hub-managed-greymatter'; + readonly runtimeBrokerFetch: true; + readonly publicFediverseDelivery: false; + readonly activityPubStatus: 'broker-display-stream-not-public-fediverse-delivery'; + readonly contentHash: string; + readonly counts: { + readonly reviewedStreamPosts: number; + readonly [key: string]: unknown; + }; + readonly consumerContract: { + readonly mode: 'cf-pages-runtime-broker-fetch'; + readonly mutationAuthority: 'tinyland.dev'; + readonly contentAuthority: 'tinyland.dev'; + readonly checkedInPostPayloads: false; + readonly spokeMutationApi: false; + readonly spokeActivityPubWorker: false; + readonly [key: string]: unknown; + }; + readonly policy: { + readonly contentTransport: 'dynamic-broker-stream'; + readonly contentMarkdownIncluded: true; + readonly draftContentIncluded: false; + readonly unreviewedContentIncluded: false; + readonly publicFediverseDelivery: false; + readonly [key: string]: unknown; + }; + readonly posts: readonly TinylandBlogBrokerPost[]; + readonly projectionTombstones: readonly unknown[]; +} + +export type TinylandBlogBrokerState = + | { readonly status: 'loading'; readonly endpoint: string } + | { + readonly status: 'ready'; + readonly endpoint: string; + readonly stream: TinylandBlogBrokerStream; + readonly posts: Post[]; + } + | { readonly status: 'unavailable'; readonly endpoint: string; readonly reason: string }; + +const FORBIDDEN_PRIVATE_FIELD_PATTERN = + /privateKey|publicKeyPem|apiKey|accessToken|privateObjectKey|s3:\/\//i; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function requireString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`blog broker stream field ${key} must be a non-empty string`); + } + return value; +} + +function requireLiteral( + record: Record, + key: string, + expected: T, +): T { + if (record[key] !== expected) { + throw new Error(`blog broker stream field ${key} must be ${String(expected)}`); + } + return expected; +} + +function requireSha256(value: string, label: string): string { + if (!/^sha256:[0-9a-f]{64}$/i.test(value)) { + throw new Error(`blog broker stream ${label} must be a sha256 digest`); + } + return value; +} + +function stringTags(value: readonly unknown[] | undefined): string[] { + return (value ?? []).filter((tag): tag is string => typeof tag === 'string' && tag.trim().length > 0); +} + +function normalizeCategory(value: string): PostCategory | undefined { + return POST_CATEGORIES.includes(value as PostCategory) ? (value as PostCategory) : undefined; +} + +function estimateReadingTime(markdown: string): number { + const text = markdown + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`[^`]*`/g, ' ') + .replace(/!\[[^\]]*]\([^)]*\)/g, ' ') + .replace(/\[([^\]]*)]\([^)]*\)/g, '$1') + .replace(/[#*_~>|\\-]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + + if (!text) return 1; + return Math.max(1, Math.round(text.split(/\s+/).length / 230)); +} + +export function validateTinylandBlogBrokerStream(data: unknown): TinylandBlogBrokerStream { + if (!isRecord(data)) { + throw new Error('blog broker stream must be an object'); + } + + const raw = JSON.stringify(data); + if (FORBIDDEN_PRIVATE_FIELD_PATTERN.test(raw)) { + throw new Error('blog broker stream contains private field-shaped data'); + } + + requireLiteral(data, 'schemaVersion', TINYLAND_BLOG_BROKER_STREAM_SCHEMA_VERSION); + requireLiteral(data, 'sourceAuthority', 'tinyland.dev'); + requireLiteral(data, 'contentAuthority', 'tinyland.dev'); + requireLiteral(data, 'spokeRef', 'jesssullivan-github-io'); + requireLiteral(data, 'spokeTarget', 'transscendsurvival.org'); + requireLiteral(data, 'routePath', '/projections/jesssullivan-github-io/blog/broker-stream.v1.json'); + requireLiteral(data, 'streamStatus', 'dynamic-hub-managed-greymatter'); + requireLiteral(data, 'managementStatus', 'hub-managed-greymatter'); + requireLiteral(data, 'runtimeBrokerFetch', true); + requireLiteral(data, 'publicFediverseDelivery', false); + requireLiteral(data, 'activityPubStatus', 'broker-display-stream-not-public-fediverse-delivery'); + requireSha256(requireString(data, 'contentHash'), 'contentHash'); + + const consumerContract = data.consumerContract; + if (!isRecord(consumerContract)) { + throw new Error('blog broker stream consumerContract must be an object'); + } + requireLiteral(consumerContract, 'mode', 'cf-pages-runtime-broker-fetch'); + requireLiteral(consumerContract, 'mutationAuthority', 'tinyland.dev'); + requireLiteral(consumerContract, 'contentAuthority', 'tinyland.dev'); + requireLiteral(consumerContract, 'checkedInPostPayloads', false); + requireLiteral(consumerContract, 'spokeMutationApi', false); + requireLiteral(consumerContract, 'spokeActivityPubWorker', false); + + const policy = data.policy; + if (!isRecord(policy)) { + throw new Error('blog broker stream policy must be an object'); + } + requireLiteral(policy, 'contentTransport', 'dynamic-broker-stream'); + requireLiteral(policy, 'contentMarkdownIncluded', true); + requireLiteral(policy, 'draftContentIncluded', false); + requireLiteral(policy, 'unreviewedContentIncluded', false); + requireLiteral(policy, 'publicFediverseDelivery', false); + + if (!Array.isArray(data.posts)) { + throw new Error('blog broker stream posts must be an array'); + } + + const posts = data.posts.map((post, index): TinylandBlogBrokerPost => { + if (!isRecord(post)) { + throw new Error(`blog broker stream post ${index} must be an object`); + } + + requireLiteral(post, 'type', 'Article'); + requireLiteral(post, 'contentFormat', 'text/markdown'); + requireLiteral(post, 'publicFediverseDelivery', false); + requireLiteral(post, 'reviewStatus', 'operator-reviewed-source-public'); + const sourceHash = requireSha256(requireString(post, 'sourceHash'), `post ${index} sourceHash`); + const contentHash = requireSha256(requireString(post, 'contentHash'), `post ${index} contentHash`); + + if (!isRecord(post.frontmatter)) { + throw new Error(`blog broker stream post ${index} frontmatter must be an object`); + } + if (!Array.isArray(post.tags)) { + throw new Error(`blog broker stream post ${index} tags must be an array`); + } + + return { + type: 'Article', + id: requireString(post, 'id'), + slug: requireString(post, 'slug'), + title: requireString(post, 'title'), + date: requireString(post, 'date'), + publishedAt: requireString(post, 'publishedAt'), + updatedAt: requireString(post, 'updatedAt'), + description: typeof post.description === 'string' ? post.description : '', + category: typeof post.category === 'string' ? post.category : 'personal', + tags: post.tags, + ...(typeof post.featureImage === 'string' && post.featureImage ? { featureImage: post.featureImage } : {}), + url: requireString(post, 'url'), + sourceRecord: requireString(post, 'sourceRecord'), + sourceHash, + contentHash, + reviewStatus: 'operator-reviewed-source-public', + frontmatter: post.frontmatter, + contentMarkdown: requireString(post, 'contentMarkdown'), + contentFormat: 'text/markdown', + publicFediverseDelivery: false, + }; + }); + + const counts = data.counts; + if (!isRecord(counts) || counts.reviewedStreamPosts !== posts.length) { + throw new Error('blog broker stream reviewedStreamPosts must match posts.length'); + } + + return { + ...(data as unknown as TinylandBlogBrokerStream), + posts, + }; +} + +export async function loadTinylandBlogBrokerStream( + fetchFn: TinylandBlogBrokerFetch, + options: TinylandBlogBrokerFetchOptions = {}, +): Promise { + const endpoint = options.endpoint ?? TINYLAND_BLOG_BROKER_STREAM_URL; + const init: RequestInit = { + headers: { Accept: 'application/json' }, + cache: 'no-store', + }; + + if (options.signal) { + init.signal = options.signal; + } + + const res = await fetchFn(endpoint, init); + if (!res.ok) { + throw new Error(`blog broker stream fetch failed: ${res.status} ${res.statusText} (${endpoint})`); + } + + return validateTinylandBlogBrokerStream(await res.json()); +} + +export function tinylandBlogBrokerPostToPost(post: TinylandBlogBrokerPost): Post { + return { + title: post.title, + slug: post.slug, + date: post.date, + description: post.description, + tags: stringTags(post.tags), + published: true, + category: normalizeCategory(post.category), + feature_image: post.featureImage, + reading_time: estimateReadingTime(post.contentMarkdown), + author_slug: 'jesssullivan', + content: post.contentMarkdown, + }; +} + +export function tinylandBlogBrokerStreamToPosts(stream: TinylandBlogBrokerStream): Post[] { + return stream.posts + .map(tinylandBlogBrokerPostToPost) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime() || a.slug.localeCompare(b.slug)); +} + +export function findTinylandBlogBrokerPost( + stream: TinylandBlogBrokerStream, + slug: string, +): TinylandBlogBrokerPost | null { + return stream.posts.find((post) => post.slug === slug) ?? null; +} + +export function summarizeTinylandBlogBrokerError(error: unknown): string { + if (error instanceof Error) { + if (error.name === 'AbortError') { + return 'broker request timed out'; + } + return error.message; + } + + return 'broker request failed'; +} diff --git a/src/lib/tinyland/marked.d.ts b/src/lib/tinyland/marked.d.ts new file mode 100644 index 0000000..c4f35f8 --- /dev/null +++ b/src/lib/tinyland/marked.d.ts @@ -0,0 +1,5 @@ +declare module 'marked' { + export const marked: { + parse(markdown: string, options?: Record): string | Promise; + }; +} diff --git a/src/lib/tinyland/runtimeMarkdown.ts b/src/lib/tinyland/runtimeMarkdown.ts new file mode 100644 index 0000000..b13a441 --- /dev/null +++ b/src/lib/tinyland/runtimeMarkdown.ts @@ -0,0 +1,25 @@ +export async function renderTrustedBrokerMarkdown(markdown: string): Promise { + if (typeof window === 'undefined') { + throw new Error('runtime broker markdown rendering is browser-only'); + } + + const [markedModule, domPurifyModule] = await Promise.all([import('marked'), import('dompurify')]); + const rendered = await markedModule.marked.parse(markdown, { + gfm: true, + breaks: false, + }); + const domPurify = domPurifyModule.default as unknown as + | { sanitize: (value: string, config?: Record) => string } + | ((window: Window) => { sanitize: (value: string, config?: Record) => string }); + const purifier = + typeof (domPurify as { sanitize?: unknown }).sanitize === 'function' + ? (domPurify as { sanitize: (value: string, config?: Record) => string }) + : (domPurify as (window: Window) => { + sanitize: (value: string, config?: Record) => string; + })(window); + + return purifier.sanitize(rendered, { + USE_PROFILES: { html: true }, + ADD_ATTR: ['target', 'rel'], + }); +} diff --git a/src/routes/blog/+page.svelte b/src/routes/blog/+page.svelte index 076c20a..3043ec5 100644 --- a/src/routes/blog/+page.svelte +++ b/src/routes/blog/+page.svelte @@ -6,11 +6,30 @@ import ProfileSidebar from '$lib/components/ProfileSidebar.svelte'; import { page } from '$app/stores'; import { browser } from '$app/environment'; + import { onMount } from 'svelte'; + import { + TINYLAND_BLOG_BROKER_STREAM_URL, + loadTinylandBlogBrokerStream, + summarizeTinylandBlogBrokerError, + tinylandBlogBrokerStreamToPosts, + type TinylandBlogBrokerState, + } from '$lib/tinyland/blogBrokerStream'; + let { data }: { data: PageData } = $props(); const POSTS_PER_PAGE = 20; + const brokerEndpoint = TINYLAND_BLOG_BROKER_STREAM_URL; + + let brokerState = $state({ + status: 'loading', + endpoint: brokerEndpoint, + }); - let totalPages = $derived(Math.ceil(data.posts.length / POSTS_PER_PAGE)); + let displayPosts = $derived( + brokerState.status === 'ready' ? brokerState.posts : data.posts + ); + + let totalPages = $derived(Math.ceil(displayPosts.length / POSTS_PER_PAGE)); let pageParam = $derived( browser ? parseInt($page.url.searchParams.get('page') || '1') : 1 ); @@ -18,16 +37,55 @@ Math.max(0, Math.min(pageParam - 1, totalPages - 1)) ); let paginatedPosts = $derived( - data.posts.slice(currentPage * POSTS_PER_PAGE, (currentPage + 1) * POSTS_PER_PAGE) + displayPosts.slice(currentPage * POSTS_PER_PAGE, (currentPage + 1) * POSTS_PER_PAGE) ); // Collect all unique tags let allTags = $derived( - [...new Set(data.posts.flatMap(p => p.tags))].sort() + [...new Set(displayPosts.flatMap(p => p.tags))].sort() ); // Recent 5 posts for sidebar - let recentPosts = $derived(data.posts.slice(0, 5)); + let recentPosts = $derived(displayPosts.slice(0, 5)); + + onMount(() => { + let cancelled = false; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10_000); + + loadTinylandBlogBrokerStream(fetch, { + endpoint: brokerEndpoint, + signal: controller.signal, + }) + .then((stream) => { + if (!cancelled) { + brokerState = { + status: 'ready', + endpoint: brokerEndpoint, + stream, + posts: tinylandBlogBrokerStreamToPosts(stream), + }; + } + }) + .catch((error: unknown) => { + if (!cancelled) { + brokerState = { + status: 'unavailable', + endpoint: brokerEndpoint, + reason: summarizeTinylandBlogBrokerError(error), + }; + } + }) + .finally(() => { + window.clearTimeout(timer); + }); + + return () => { + cancelled = true; + window.clearTimeout(timer); + controller.abort(); + }; + }); @@ -42,9 +100,19 @@
+
+ {#if brokerState.status === 'ready'} + Tinyland broker stream loaded. + {:else if brokerState.status === 'unavailable'} + Tinyland broker stream unavailable: {brokerState.reason} + {:else} + Tinyland broker stream loading. + {/if} +
+

Blog

- {data.posts.length} posts + {displayPosts.length} posts
diff --git a/src/routes/blog/[slug]/+page.svelte b/src/routes/blog/[slug]/+page.svelte index 4a29600..b2835f3 100644 --- a/src/routes/blog/[slug]/+page.svelte +++ b/src/routes/blog/[slug]/+page.svelte @@ -7,9 +7,62 @@ import ReadingProgressRing from '$lib/components/ReadingProgressRing.svelte'; import { onMount } from 'svelte'; import { browser } from '$app/environment'; + import { + TINYLAND_BLOG_BROKER_STREAM_URL, + findTinylandBlogBrokerPost, + loadTinylandBlogBrokerStream, + summarizeTinylandBlogBrokerError, + tinylandBlogBrokerPostToPost, + type TinylandBlogBrokerPost, + } from '$lib/tinyland/blogBrokerStream'; + import { renderTrustedBrokerMarkdown } from '$lib/tinyland/runtimeMarkdown'; + let { data }: { data: PageData } = $props(); + type ActiveMetadata = { + title: string; + slug: string; + date: string; + published?: boolean; + description?: string; + tags?: string[]; + original_url?: string; + category?: string; + feature_image?: string; + }; + let readingProgress = $state(0); + let brokerPost = $state(null); + let brokerHtml = $state(''); + let brokerStatus = $state<'idle' | 'loading' | 'ready' | 'unavailable'>('idle'); + let brokerUnavailableReason = $state(''); + + let activePost = $derived(brokerPost ? tinylandBlogBrokerPostToPost(brokerPost) : null); + let activeMetadata = $derived( + activePost + ? { + ...data.metadata, + title: activePost.title, + slug: activePost.slug, + date: activePost.date, + description: activePost.description, + tags: activePost.tags, + category: activePost.category, + feature_image: activePost.feature_image, + } + : data.metadata + ) as ActiveMetadata; + let activeReadingTime = $derived(activePost?.reading_time ?? data.reading_time); + let activeImageUrl = $derived(resolveSiteImageUrl(activeMetadata.feature_image)); + let activeOriginalUrl = $derived(activeMetadata.original_url); + let activeOriginalHost = $derived(activeOriginalUrl ? new URL(activeOriginalUrl).hostname : ''); + + function resolveSiteImageUrl(value: string | undefined): string { + if (!value) return 'https://transscendsurvival.org/images/header.png'; + if (/^https?:\/\//.test(value)) return value; + const path = value.startsWith('/') ? value : `/${value}`; + return `https://transscendsurvival.org${path}`; + } function updateReadingProgress() { const article = document.querySelector('article'); @@ -77,48 +130,95 @@ heading.prepend(link); }); }); + + onMount(() => { + const slug = data.brokerSlug; + if (!slug) return; + + let cancelled = false; + const controller = new AbortController(); + const timer = window.setTimeout(() => controller.abort(), 10_000); + brokerStatus = 'loading'; + + loadTinylandBlogBrokerStream(fetch, { + endpoint: TINYLAND_BLOG_BROKER_STREAM_URL, + signal: controller.signal, + }) + .then(async (stream) => { + const post = findTinylandBlogBrokerPost(stream, slug); + if (!post) { + if (!cancelled && data.brokerOnly) { + brokerStatus = 'unavailable'; + brokerUnavailableReason = 'post is not in the reviewed Tinyland broker stream'; + } + return; + } + + const html = await renderTrustedBrokerMarkdown(post.contentMarkdown); + if (!cancelled) { + brokerPost = post; + brokerHtml = html; + brokerStatus = 'ready'; + } + }) + .catch((error: unknown) => { + if (!cancelled) { + brokerStatus = 'unavailable'; + brokerUnavailableReason = summarizeTinylandBlogBrokerError(error); + } + }) + .finally(() => { + window.clearTimeout(timer); + }); + + return () => { + cancelled = true; + window.clearTimeout(timer); + controller.abort(); + }; + }); - {data.metadata.title} | transscendsurvival.org - {#if data.metadata.description} - + {activeMetadata.title} | transscendsurvival.org + {#if activeMetadata.description} + {/if} - + - + - + - {#if data.metadata.description} - + {#if activeMetadata.description} + {/if} - {#if data.metadata.tags?.length} - {#each data.metadata.tags as tag} + {#if activeMetadata.tags?.length} + {#each activeMetadata.tags as tag} {/each} {/if} - + - - {#if data.metadata.description} - + + {#if activeMetadata.description} + {/if} - - + + {@html ``} @@ -135,29 +235,41 @@ {/if}
+
+ {#if brokerStatus === 'ready'} + Tinyland broker post loaded. + {:else if brokerStatus === 'unavailable'} + Tinyland broker post unavailable: {brokerUnavailableReason} + {:else if brokerStatus === 'loading'} + Tinyland broker post loading. + {/if} +
+
-

{data.metadata.title}

+

{activeMetadata.title}

- - {#if data.reading_time} + {#if activeMetadata.date} + + {/if} + {#if activeReadingTime} · - {data.reading_time} min read + {activeReadingTime} min read {/if} - {#if data.metadata.category} + {#if activeMetadata.category} · - {data.metadata.category} + {activeMetadata.category} {/if}
- {#if data.metadata.tags?.length} + {#if activeMetadata.tags?.length}
- {#each data.metadata.tags as tag} + {#each activeMetadata.tags as tag}
- {@render data.content()} + {#if brokerHtml} + {@html brokerHtml} + {:else if data.content} + {@render data.content()} + {:else if brokerStatus === 'unavailable'} +

Post unavailable.

+ {:else} +

Loading post.

+ {/if}
- {#if data.metadata.original_url} + {#if activeOriginalUrl}

- Originally published at {new URL(data.metadata.original_url).hostname} + Originally published at {activeOriginalHost}

{/if} diff --git a/src/routes/blog/[slug]/+page.ts b/src/routes/blog/[slug]/+page.ts index 089b602..418aea8 100644 --- a/src/routes/blog/[slug]/+page.ts +++ b/src/routes/blog/[slug]/+page.ts @@ -1,10 +1,10 @@ import type { PageLoad, EntryGenerator } from './$types'; -import { error } from '@sveltejs/kit'; import searchIndexData from '../../../../static/search-index.json'; interface PostMeta { title: string; date: string; + slug: string; published?: boolean; description?: string; tags?: string[]; @@ -90,13 +90,31 @@ export const load: PageLoad = async ({ params }) => { return { content: post.default, - metadata: post.metadata, + metadata: { ...post.metadata, slug: params.slug }, reading_time, prev, next, - relatedPosts + relatedPosts, + brokerSlug: params.slug, + brokerOnly: false, }; } - error(404, 'Post not found'); + return { + content: null, + metadata: { + title: params.slug, + slug: params.slug, + date: '', + description: '', + tags: [], + published: true, + }, + reading_time: null, + prev: null, + next: null, + relatedPosts: [], + brokerSlug: params.slug, + brokerOnly: true, + }; }; From c205e960605b98d80da50a467c25952cf66bf40e Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 01:17:15 -0400 Subject: [PATCH 2/6] fix(pulse): align broker demo contract with hub --- e2e/pulse-brokered-stream.spec.ts | 6 +++--- packages/pulse-core/src/schema/ap-stream-demo.ts | 2 +- src/lib/components/pulse/PulseApStreamDemoPanel.test.ts | 2 +- src/lib/pulse/apStreamDemo.test.ts | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/e2e/pulse-brokered-stream.spec.ts b/e2e/pulse-brokered-stream.spec.ts index a8100e5..c0fa78a 100644 --- a/e2e/pulse-brokered-stream.spec.ts +++ b/e2e/pulse-brokered-stream.spec.ts @@ -1,18 +1,18 @@ import { test, expect } from '@playwright/test'; -const endpoint = 'https://tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json'; +const endpoint = 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json'; const endpointPattern = '**/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json**'; const liveDemo = { schemaVersion: 'tinyland.pulse.ap-stream-demo.v1', generatedAt: '2026-05-10T13:00:00.000Z', sourceAuthority: 'tinyland.dev', - sourceAuthorityUrl: 'https://tinyland.dev', + sourceAuthorityUrl: 'https://hub.tinyland.dev', sourceSnapshotId: 'tinyland-jesssullivan-pulse-static-seed-2026-05-10', contentHash: 'sha256:fc3b04ec97946d6777e5245040b09a3ead296a9bf4614d0fea7df2d3cfb2ccb7', policyVersion: 'm1-2026-04-27', projectionKind: 'pulse-ap-stream-demo', - demoStatus: 'controlled-static-source-live-broker-demo', + demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, activityPubStatus: 'ap-shaped-projection-only', spokeRef: 'jesssullivan-github-io', diff --git a/packages/pulse-core/src/schema/ap-stream-demo.ts b/packages/pulse-core/src/schema/ap-stream-demo.ts index d65366e..5dafc7a 100644 --- a/packages/pulse-core/src/schema/ap-stream-demo.ts +++ b/packages/pulse-core/src/schema/ap-stream-demo.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { IsoTimestampSchema } from './event.js'; export const PULSE_AP_STREAM_DEMO_SCHEMA_VERSION = 'tinyland.pulse.ap-stream-demo.v1'; -export const PULSE_AP_STREAM_DEMO_STATUS = 'controlled-static-source-live-broker-demo'; +export const PULSE_AP_STREAM_DEMO_STATUS = 'controlled-broker-source-demo'; export const PulseApStreamDemoTagSchema = z .object({ diff --git a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts index d3faa85..dcc3911 100644 --- a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts +++ b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts @@ -15,7 +15,7 @@ const readyState: PulseApStreamDemoPanelState = { contentHash: 'sha256:fc3b04ec97946d6777e5245040b09a3ead296a9bf4614d0fea7df2d3cfb2ccb7', policyVersion: 'm1-2026-04-27', projectionKind: 'pulse-ap-stream-demo', - demoStatus: 'controlled-static-source-live-broker-demo', + demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, activityPubStatus: 'ap-shaped-projection-only', spokeRef: 'jesssullivan-github-io', diff --git a/src/lib/pulse/apStreamDemo.test.ts b/src/lib/pulse/apStreamDemo.test.ts index 1c130c8..e09b7aa 100644 --- a/src/lib/pulse/apStreamDemo.test.ts +++ b/src/lib/pulse/apStreamDemo.test.ts @@ -15,7 +15,7 @@ const validDemo: PulseApStreamDemo = { contentHash: 'sha256:fc3b04ec97946d6777e5245040b09a3ead296a9bf4614d0fea7df2d3cfb2ccb7', policyVersion: 'm1-2026-04-27', projectionKind: 'pulse-ap-stream-demo', - demoStatus: 'controlled-static-source-live-broker-demo', + demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, activityPubStatus: 'ap-shaped-projection-only', spokeRef: 'jesssullivan-github-io', From 833e2c7d36c82ebe6302ee9861fffb316569b1e5 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 01:27:36 -0400 Subject: [PATCH 3/6] fix(pulse): accept broker projection demo status --- e2e/pulse-brokered-stream.spec.ts | 7 +++---- packages/pulse-core/src/schema/ap-stream-demo.ts | 2 +- packages/pulse-core/test/schema.test.ts | 2 +- src/lib/components/pulse/PulseApStreamDemoPanel.test.ts | 2 +- src/lib/pulse/apStreamDemo.test.ts | 2 +- 5 files changed, 7 insertions(+), 8 deletions(-) diff --git a/e2e/pulse-brokered-stream.spec.ts b/e2e/pulse-brokered-stream.spec.ts index c0fa78a..187bac4 100644 --- a/e2e/pulse-brokered-stream.spec.ts +++ b/e2e/pulse-brokered-stream.spec.ts @@ -1,7 +1,6 @@ import { test, expect } from '@playwright/test'; const endpoint = 'https://hub.tinyland.dev/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json'; -const endpointPattern = '**/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json**'; const liveDemo = { schemaVersion: 'tinyland.pulse.ap-stream-demo.v1', @@ -14,7 +13,7 @@ const liveDemo = { projectionKind: 'pulse-ap-stream-demo', demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, - activityPubStatus: 'ap-shaped-projection-only', + activityPubStatus: 'broker-projection-only', spokeRef: 'jesssullivan-github-io', spokeTarget: 'transscendsurvival.org', routePath: '/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', @@ -39,7 +38,7 @@ const liveDemo = { test.describe('Pulse brokered stream lab', () => { test('renders AP-shaped broker data from the live Tinyland endpoint', async ({ page }) => { - await page.route(endpointPattern, (route) => + await page.route(endpoint, (route) => route.fulfill({ status: 200, contentType: 'application/json', @@ -61,7 +60,7 @@ test.describe('Pulse brokered stream lab', () => { }); test('shows unavailable instead of falling back to checked-in static data', async ({ page }) => { - await page.route(endpointPattern, (route) => + await page.route(endpoint, (route) => route.fulfill({ status: 503, contentType: 'application/json', diff --git a/packages/pulse-core/src/schema/ap-stream-demo.ts b/packages/pulse-core/src/schema/ap-stream-demo.ts index 5dafc7a..83ef1bb 100644 --- a/packages/pulse-core/src/schema/ap-stream-demo.ts +++ b/packages/pulse-core/src/schema/ap-stream-demo.ts @@ -45,7 +45,7 @@ export const PulseApStreamDemoSchema = z projectionKind: z.literal('pulse-ap-stream-demo'), demoStatus: z.literal(PULSE_AP_STREAM_DEMO_STATUS), publicFediverseDelivery: z.literal(false), - activityPubStatus: z.literal('ap-shaped-projection-only'), + activityPubStatus: z.literal('broker-projection-only'), spokeRef: z.string().min(1), spokeTarget: z.string().min(1), routePath: z.string().regex(/^\/projections\/[^/]+\/pulse\/ap-stream-demo\.v1\.json$/), diff --git a/packages/pulse-core/test/schema.test.ts b/packages/pulse-core/test/schema.test.ts index 6c76b9c..9141b1f 100644 --- a/packages/pulse-core/test/schema.test.ts +++ b/packages/pulse-core/test/schema.test.ts @@ -162,7 +162,7 @@ describe('schema/ap-stream-demo', () => { projectionKind: 'pulse-ap-stream-demo', demoStatus: PULSE_AP_STREAM_DEMO_STATUS, publicFediverseDelivery: false, - activityPubStatus: 'ap-shaped-projection-only', + activityPubStatus: 'broker-projection-only', spokeRef: 'jesssullivan-github-io', spokeTarget: 'transscendsurvival.org', routePath: '/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', diff --git a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts index dcc3911..0560240 100644 --- a/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts +++ b/src/lib/components/pulse/PulseApStreamDemoPanel.test.ts @@ -17,7 +17,7 @@ const readyState: PulseApStreamDemoPanelState = { projectionKind: 'pulse-ap-stream-demo', demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, - activityPubStatus: 'ap-shaped-projection-only', + activityPubStatus: 'broker-projection-only', spokeRef: 'jesssullivan-github-io', spokeTarget: 'transscendsurvival.org', routePath: '/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', diff --git a/src/lib/pulse/apStreamDemo.test.ts b/src/lib/pulse/apStreamDemo.test.ts index e09b7aa..131ccb4 100644 --- a/src/lib/pulse/apStreamDemo.test.ts +++ b/src/lib/pulse/apStreamDemo.test.ts @@ -17,7 +17,7 @@ const validDemo: PulseApStreamDemo = { projectionKind: 'pulse-ap-stream-demo', demoStatus: 'controlled-broker-source-demo', publicFediverseDelivery: false, - activityPubStatus: 'ap-shaped-projection-only', + activityPubStatus: 'broker-projection-only', spokeRef: 'jesssullivan-github-io', spokeTarget: 'transscendsurvival.org', routePath: '/projections/jesssullivan-github-io/pulse/ap-stream-demo.v1.json', From 7faa5bc09aff107f799a219d083ee6b4e8a2c321 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 01:41:25 -0400 Subject: [PATCH 4/6] fix(pulse): allow hub broker in CSP --- src/app.html | 2 +- src/lib/app-csp.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 src/lib/app-csp.test.ts diff --git a/src/app.html b/src/app.html index 877bf2e..08cc2a0 100644 --- a/src/app.html +++ b/src/app.html @@ -4,7 +4,7 @@ - + diff --git a/src/lib/app-csp.test.ts b/src/lib/app-csp.test.ts new file mode 100644 index 0000000..d651de0 --- /dev/null +++ b/src/lib/app-csp.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const appHtml = readFileSync(new URL('../app.html', import.meta.url), 'utf8'); + +function cspDirective(name: string): string[] { + const content = appHtml.match(/http-equiv="Content-Security-Policy" content="([^"]+)"/)?.[1]; + if (!content) { + throw new Error('Content-Security-Policy meta tag not found'); + } + + const directive = content + .split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(`${name} `)); + + if (!directive) { + throw new Error(`${name} directive not found`); + } + + return directive.split(/\s+/).slice(1); +} + +describe('app CSP', () => { + it('allows the Tinyland hub broker origin for runtime Pulse/blog streams', () => { + expect(cspDirective('connect-src')).toEqual( + expect.arrayContaining(['https://hub.tinyland.dev', 'https://tinyland.dev']), + ); + }); +}); From 956f90a92a058b525723c6cbc782e3e646f50957 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 01:50:37 -0400 Subject: [PATCH 5/6] ci: bound report-only lighthouse step --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e57f9c..2fa31dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,7 @@ jobs: - name: Lighthouse CI id: lhci + timeout-minutes: 5 run: | npm install -g @lhci/cli lhci autorun From 2317f461f35d6e43752c874e83528b916bf2d6b8 Mon Sep 17 00:00:00 2001 From: Jess Sullivan Date: Tue, 19 May 2026 01:59:35 -0400 Subject: [PATCH 6/6] ci: hard-timeout report-only lighthouse --- .github/workflows/ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fa31dc..cd048cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -119,8 +119,7 @@ jobs: id: lhci timeout-minutes: 5 run: | - npm install -g @lhci/cli - lhci autorun + timeout 5m bash -lc 'npm install -g @lhci/cli && lhci autorun' continue-on-error: true - name: Upload Lighthouse results