diff --git a/public/assets/around-regular.woff2 b/public/assets/around-regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f21ad2c1317214539f1323a662502a12b361e74b GIT binary patch literal 9376 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 literal 0 HcmV?d00001 diff --git a/public/index.md b/public/index.md index f037ed7..9003c20 100644 --- a/public/index.md +++ b/public/index.md @@ -1,13 +1,3 @@ -# thought stream +# Stream -A durable event fabric for agents, sources, and the evidence between them. - -thought stream separates observation, model execution, projection, and action. Sources become append-only events. Consumers run against bounded context. Outputs settle with lineage and terminal receipts. External actions remain separate capabilities. - -## What this site contains - -- Public architecture and security documentation compiled from reviewed repository files. -- ATProto OAuth endpoints used to authenticate the private inspector. -- No live events, source names, manifests, traces, credentials, service metadata, or runtime state. - -The inspector is private. Public documentation describes the system; it is not a window into the system's data. +A private feed for Cameron and the agents working with him. diff --git a/spec/security.md b/spec/security.md index 88c6c6d..712a05f 100644 --- a/spec/security.md +++ b/spec/security.md @@ -91,6 +91,8 @@ Basic Auth remains a separately configured break-glass path while OAuth is being Review is the sole browser mutation exception. Basic remains read-only. An allowlisted OAuth session and CSRF token authorize the public proxy to sign one exact bounded decision body with a separately injected capability. The inspector verifies method, normalized path, body digest, freshness, and one-time nonce before applying the fixed append-only schema. Cookies, OAuth tokens, DIDs, CSRF values, and Basic credentials never reach Jazz or the loopback inspector. Missing capability configuration leaves the inspector entirely read-only. See [`review.md`](review.md) and [`web-auth.md`](web-auth.md). +The authenticated inspector's Bluesky renderer may fetch public image bytes only through one fixed loopback route. The trusted inspector parses the requested URL and requires HTTPS, no credentials, the exact `cdn.bsky.app` host with default port, and an `/img/` path. Fetches have a hard deadline, reject redirects, bound declared and streamed bytes, allow only JPEG/PNG/WebP/GIF response types, and verify matching file magic before returning same-origin bytes. The route has no Jazz, credential, arbitrary-host, generic-proxy, HTML, SVG, or public-route authority. Its bounded memory cache contains only already-public CDN bytes and expires entries; the authenticated proxy still applies `no-store` to browser responses. + ## Dependency audit residual The July 26, 2026 production audit has one unresolved high-severity finding: `sharp@0.34.5` is inherited through `@letta-ai/letta-agent-sdk -> @letta-ai/letta-code`, while the advisory requires `sharp>=0.35.0`. Review prompt generation, browser grading, capability verification, and JSONL export do not invoke image decoding, so this is not exposed by the Review path. It is still a project-level dependency finding and remains visible until the upstream SDK adopts a compatible patched Sharp version or a separately tested major-minor override is approved. Direct `fast-xml-parser` and compatible transitive `protobufjs`/`brace-expansion` findings are patched; a clean audit must not be claimed while Sharp remains. diff --git a/spec/ui.md b/spec/ui.md index 21279d3..b3a7e90 100644 --- a/spec/ui.md +++ b/spec/ui.md @@ -2,7 +2,7 @@ ## Purpose -The first interface proves that the stream and agents are alive. It uses one narrow chronological column modeled on Cameron's public site rather than a dashboard grid. +The first interface proves that the stream and agents are alive. It uses one narrow chronological column modeled on Cameron's public site rather than a dashboard grid. Its visible name is **Stream**. ## Root view @@ -23,6 +23,10 @@ Deterministic transforms are labeled **rules**, not agents. The interface states Filters stay collapsed above the feed until requested. The current controls cover source, activity kind, processing state, and text. +On narrow screens, the private interface behaves as an app shell rather than a compressed desktop document. The title stays in one compact sticky header, the feed runs edge to edge inside safe-area padding, and the primary views move to a fixed bottom navigation bar with touch-sized controls. Detail selection replaces the feed in the same viewport and preserves the existing back affordance. The page publishes standalone-app metadata and an exact private manifest/icon route; it does not cache private HTML, JSON, media, or Jazz-derived content offline. + +Bluesky avatars and embed thumbnails are rendered through one authenticated same-origin media route because the private web boundary intentionally denies arbitrary third-party image loads. The route accepts only HTTPS `cdn.bsky.app/img/*` URLs, follows no redirects, applies strict byte, timeout, content-type, and image-magic bounds, and returns no upstream detail on failure. Full-size post links remain ordinary external navigation. A failed image is removed from the card rather than leaving a large broken placeholder. + The inspector exposes a read-only adapter inventory with public-safe release metadata, canonical lifecycle status/generation, a separately rendered active deployment binding when one exists, selecting consumers, bound runs, and output event ids. It must distinguish the execution adapter from the learned model adapter, and release status from deployment binding, and must never render checkpoint paths or resolved environment values. The inspector exposes a read-only metadata-only artifact catalog and per-artifact detail view. The catalog lists artifact id, version, kind, title, summary, media type, byte count, SHA-256 prefix, visibility, provenance label, and supersession status, never bytes. Detail resolves and re-verifies the private blob on demand: text is escaped and images use a separate private content route. Artifact bytes are never exposed through public routes. See [`artifacts.md`](artifacts.md). @@ -92,7 +96,7 @@ exists. The same loopback web process may serve an allowlisted public surface, but public and private routing are separate capabilities rather than a shared fallback: -- `/`, `/docs`, and named `/docs/*` pages render only from an explicit repository-owned public-content allowlist. Route input can never select a filesystem path. +- `/` is a minimal Cameron.stream-shaped landing page: the Around-set `Stream` wordmark, one short public-safe sentence, one `Log in` form posting directly to the OAuth flow, and one `code` footer link. It contains no docs navigation, private-data counts, or architecture summary. `/docs` and named `/docs/*` pages remain directly addressable from an explicit repository-owned public-content allowlist, but the landing page does not promote them. Route input can never select a filesystem path. - `/oauth/client-metadata.json`, `/oauth/jwks.json`, `/oauth/login`, `/oauth/callback`, and `/oauth/logout` are the only public authentication routes. - `/inspector` and `/inspector/*` are the only routes that may forward to the loopback inspector. The prefix is removed before forwarding. - Unknown routes return a local content-dark `404`; they never fall through to the inspector. diff --git a/spec/web-auth.md b/spec/web-auth.md index af97bdd..8cd16cc 100644 --- a/spec/web-auth.md +++ b/spec/web-auth.md @@ -4,7 +4,7 @@ Protected assets are private Jazz events, source identities, manifests, traces, runtime paths and metadata, OAuth state and DPoP keys, access and refresh tokens, the confidential-client private key, browser sessions, and the Basic break-glass credential. -Public assets are the four reviewed Markdown pages, OAuth client metadata, and the public half of the client JWKS. +Public assets are the four reviewed Markdown pages, one exact validated Around WOFF2 embedded into their generated HTML, OAuth client metadata, and the public half of the client JWKS. ## Trust boundaries @@ -18,7 +18,7 @@ Public assets are the four reviewed Markdown pages, OAuth client metadata, and t | Route | Methods | Authority | Upstream access | | --- | --- | --- | --- | -| `/`, `/docs`, `/docs/architecture`, `/docs/security` | GET, HEAD | public reviewed files | none | +| `/`, `/docs`, `/docs/architecture`, `/docs/security` | GET, HEAD | public reviewed files; `/` contains the direct self-origin OAuth login form | none | | `/oauth/client-metadata.json`, `/oauth/jwks.json` | GET, HEAD | public OAuth discovery | none | | `/oauth/login` | GET, HEAD, POST | public flow initiation | authorization server only through SDK | | `/oauth/callback` | GET | one-time browser-bound state | token endpoint only through SDK | @@ -33,7 +33,7 @@ Public assets are the four reviewed Markdown pages, OAuth client metadata, and t Encoded traversal, unknown paths, former root `/api` paths, and unsupported methods terminate in the public proxy. They never become arbitrary filesystem paths and never fall through to the inspector. `/inspector` redirects to `/inspector/` only after authentication so the inspector's relative `api/...` requests remain inside the private prefix. -Public pages and inspector data responses use an inert `script-src 'none'` policy. Authenticated inspector HTML receives a separate route-scoped policy that permits its audited inline loader and same-origin snapshot requests while forbidding forms and framing. The proxy selects this policy from the trusted loopback response content type; public routes never inherit it. +Public pages and inspector data responses use an inert `script-src 'none'` policy. The public page policy permits only the embedded validated WOFF2 as a `data:` font and self-origin form submission; it does not permit data images or scripts. Authenticated inspector HTML receives a separate route-scoped policy that permits its audited inline loader, same-origin snapshot/media requests, and one same-origin no-cache service worker while forbidding third-party images, forms, and framing. The proxy selects this policy from the trusted loopback response content type; public routes never inherit it. ### Credential forwarding and response smuggling diff --git a/src/projections/activity.ts b/src/projections/activity.ts index ac210b2..893c309 100644 --- a/src/projections/activity.ts +++ b/src/projections/activity.ts @@ -412,6 +412,10 @@ function truncate(value: string, limit: number): string { function atUriToWebUrl(value: string): string { const match = value.match(/^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/([^/]+)$/); return match - ? `https://bsky.app/profile/${encodeURIComponent(match[1]!)}/post/${encodeURIComponent(match[2]!)}` + ? `https://bsky.app/profile/${blueskyActorPathSegment(match[1]!)}/post/${encodeURIComponent(match[2]!)}` : ""; } + +function blueskyActorPathSegment(value: string): string { + return encodeURIComponent(value).replaceAll("%3A", ":"); +} diff --git a/src/web/authenticated-proxy.ts b/src/web/authenticated-proxy.ts index 6f74525..1cead48 100644 --- a/src/web/authenticated-proxy.ts +++ b/src/web/authenticated-proxy.ts @@ -34,7 +34,7 @@ export interface AuthenticatedInspectorProxyOptions { const SECURITY_HEADERS = { "cache-control": "no-store", - "content-security-policy": "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + "content-security-policy": "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; font-src data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", "permissions-policy": "camera=(), microphone=(), geolocation=(), payment=(), usb=()", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", @@ -45,7 +45,7 @@ const OAUTH_LOGIN_CONTENT_SECURITY_POLICY = "default-src 'self'; script-src 'none'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self' https:"; const INSPECTOR_HTML_CONTENT_SECURITY_POLICY = - "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; + "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"; const FORWARDED_REQUEST_HEADERS = new Set([ "accept", diff --git a/src/web/inspector.ts b/src/web/inspector.ts index ed155e8..eba4efc 100644 --- a/src/web/inspector.ts +++ b/src/web/inspector.ts @@ -1,4 +1,6 @@ import http, { type IncomingMessage, type ServerResponse } from "node:http"; +import fs from "node:fs/promises"; +import path from "node:path"; import type { JazzThoughtStore } from "../jazz/store.js"; import { buildRecentRootActivity, @@ -42,6 +44,24 @@ const RUN_TERMINAL_EVENT_TYPES = [ const BLUESKY_POST_URI_PATTERN = /^at:\/\/[^/]{3,256}\/app\.bsky\.feed\.post\/[a-zA-Z0-9._~:-]{1,256}$/; const SAFE_CID_PATTERN = /^[a-zA-Z0-9]{8,128}$/; const BLUESKY_INLAY_CACHE_TTL_MS = 10 * 60_000; +const BLUESKY_MEDIA_CACHE_TTL_MS = 10 * 60_000; +const BLUESKY_MEDIA_CACHE_MAX_BYTES = 24 * 1024 * 1024; +const BLUESKY_MEDIA_CACHE_MAX_ENTRIES = 96; +const BLUESKY_MEDIA_MAX_BYTES = 7 * 1024 * 1024; +const BLUESKY_MEDIA_CONTENT_TYPES = new Set(["image/jpeg", "image/png", "image/webp", "image/gif"]); +const STREAM_APP_MANIFEST = `${JSON.stringify({ + name: "Stream", + short_name: "Stream", + id: "./", + start_url: "./", + scope: "./", + display: "standalone", + background_color: "#0a0a0a", + theme_color: "#0a0a0a", + icons: [{ src: "assets/stream-icon.svg", sizes: "any", type: "image/svg+xml", purpose: "any maskable" }], +})}\n`; +const STREAM_APP_ICON = `S`; +const STREAM_SERVICE_WORKER = "self.addEventListener('install',()=>self.skipWaiting());self.addEventListener('activate',event=>event.waitUntil(self.clients.claim()));\n"; interface BlueskyPostInlay { uri: string; @@ -84,6 +104,8 @@ const blueskyPostViewSchema = z.object({ }).passthrough(); const blueskyPostResponseSchema = z.object({ posts: z.array(blueskyPostViewSchema) }); const blueskyInlayCache = new Map(); +const blueskyMediaCache = new Map(); +let blueskyMediaCacheBytes = 0; let aroundFontCache: Buffer | undefined; export interface InspectorServerOptions { @@ -211,6 +233,18 @@ async function handleRequest( send(response, 200, "text/html; charset=utf-8", renderInspectorHtml()); return; } + if (url.pathname === "/manifest.webmanifest") { + send(response, 200, "application/manifest+json; charset=utf-8", STREAM_APP_MANIFEST); + return; + } + if (url.pathname === "/assets/stream-icon.svg") { + send(response, 200, "image/svg+xml; charset=utf-8", STREAM_APP_ICON); + return; + } + if (url.pathname === "/sw.js") { + send(response, 200, "text/javascript; charset=utf-8", STREAM_SERVICE_WORKER); + return; + } if (url.pathname === "/assets/around-regular.woff2") { try { aroundFontCache ??= await loadAroundFont(); @@ -220,6 +254,23 @@ async function handleRequest( } return; } + if (url.pathname === "/api/inlays/bluesky-media") { + const mediaReferences = url.searchParams.getAll("url"); + const source = mediaReferences.length === 1 && [...url.searchParams.keys()].every((key) => key === "url") + ? safeBlueskyMediaUrl(mediaReferences[0]) + : undefined; + if (!source) { + sendJson(response, 400, { error: "Bluesky media reference is invalid" }); + return; + } + try { + const media = await loadBlueskyMedia(source); + sendBytes(response, 200, media.contentType, media.bytes, "private, max-age=600"); + } catch { + sendJson(response, 502, { error: "Bluesky media is unavailable" }); + } + return; + } if (url.pathname === "/api/inlays/bluesky") { const uri = url.searchParams.get("uri"); const cid = url.searchParams.get("cid") ?? undefined; @@ -629,18 +680,88 @@ async function loadBlueskyPostInlay(uri: string, expectedCid?: string): Promise< } async function loadAroundFont(): Promise { - const response = await fetch("https://cameron.stream/public/fonts/around-regular.woff2?v=20260712-12", { - headers: { accept: "font/woff2" }, - signal: AbortSignal.timeout(4_000), - }); - if (!response.ok) throw new Error(`Cameron.stream returned ${response.status}`); - const bytes = Buffer.from(await response.arrayBuffer()); + const fontPath = path.resolve(process.cwd(), "public/assets/around-regular.woff2"); + const stat = await fs.lstat(fontPath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Around font is not a regular file"); + const bytes = await fs.readFile(fontPath); if (bytes.length < 4 || bytes.length > 65_536 || bytes.subarray(0, 4).toString("ascii") !== "wOF2") { - throw new Error("Cameron.stream returned an invalid Around font"); + throw new Error("Around font is invalid"); } return bytes; } +async function loadBlueskyMedia(source: string): Promise<{ contentType: string; bytes: Buffer }> { + const cached = blueskyMediaCache.get(source); + if (cached && cached.expiresAt > Date.now()) return cached; + if (cached) removeBlueskyMediaCacheEntry(source, cached); + + const response = await fetch(source, { + headers: { accept: "image/webp,image/png,image/jpeg,image/gif" }, + redirect: "error", + signal: AbortSignal.timeout(5_000), + }); + if (!response.ok || !response.body) throw new Error("Bluesky CDN request failed"); + const contentType = (response.headers.get("content-type") ?? "").split(";", 1)[0]!.trim().toLowerCase(); + if (!BLUESKY_MEDIA_CONTENT_TYPES.has(contentType)) { + await response.body.cancel(); + throw new Error("Bluesky CDN media type is unsupported"); + } + const declaredLength = response.headers.get("content-length"); + if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > BLUESKY_MEDIA_MAX_BYTES)) { + await response.body.cancel(); + throw new Error("Bluesky CDN media is oversized"); + } + const reader = response.body.getReader(); + const chunks: Buffer[] = []; + let totalBytes = 0; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + const bytes = Buffer.from(chunk.value); + totalBytes += bytes.length; + if (totalBytes > BLUESKY_MEDIA_MAX_BYTES) { + await reader.cancel(); + throw new Error("Bluesky CDN media is oversized"); + } + chunks.push(bytes); + } + const bytes = Buffer.concat(chunks, totalBytes); + if (!matchesImageMagic(bytes, contentType)) throw new Error("Bluesky CDN media bytes are invalid"); + const entry = { expiresAt: Date.now() + BLUESKY_MEDIA_CACHE_TTL_MS, contentType, bytes }; + blueskyMediaCache.set(source, entry); + blueskyMediaCacheBytes += bytes.length; + trimBlueskyMediaCache(); + return entry; +} + +function trimBlueskyMediaCache(): void { + const now = Date.now(); + for (const [key, entry] of blueskyMediaCache) { + if (entry.expiresAt <= now) removeBlueskyMediaCacheEntry(key, entry); + } + while (blueskyMediaCache.size > BLUESKY_MEDIA_CACHE_MAX_ENTRIES || blueskyMediaCacheBytes > BLUESKY_MEDIA_CACHE_MAX_BYTES) { + const oldest = blueskyMediaCache.entries().next().value as [string, { expiresAt: number; contentType: string; bytes: Buffer }] | undefined; + if (!oldest) break; + removeBlueskyMediaCacheEntry(oldest[0], oldest[1]); + } +} + +function removeBlueskyMediaCacheEntry( + key: string, + entry: { expiresAt: number; contentType: string; bytes: Buffer }, +): void { + if (!blueskyMediaCache.delete(key)) return; + blueskyMediaCacheBytes = Math.max(0, blueskyMediaCacheBytes - entry.bytes.length); +} + +function matchesImageMagic(bytes: Buffer, contentType: string): boolean { + if (contentType === "image/jpeg") return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + if (contentType === "image/png") return bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])); + if (contentType === "image/webp") return bytes.length >= 12 && bytes.subarray(0, 4).toString("ascii") === "RIFF" && bytes.subarray(8, 12).toString("ascii") === "WEBP"; + if (contentType === "image/gif") return bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(bytes.subarray(0, 6).toString("ascii")); + return false; +} + function blueskyEmbed(value: unknown): Pick { const embed = objectValue(value); const type = stringValue(embed?.$type); @@ -648,7 +769,7 @@ function blueskyEmbed(value: unknown): Pick { const image = objectValue(candidate); - const thumb = safeHttpsUrl(image?.thumb); + const thumb = blueskyMediaProxyPath(image?.thumb); const fullsize = safeHttpsUrl(image?.fullsize); if (!thumb || !fullsize) return []; return [{ thumb, fullsize, alt: stringValue(image?.alt) ?? "" }]; @@ -656,7 +777,7 @@ function blueskyEmbed(value: unknown): Pick): BlueskyPostInlay["author"] { const handle = stringValue(value.handle) ?? "unknown.handle"; + const avatar = blueskyMediaProxyPath(value.avatar); return { displayName: stringValue(value.displayName)?.trim() || handle, handle, - ...(safeHttpsUrl(value.avatar) ? { avatar: safeHttpsUrl(value.avatar)! } : {}), + ...(avatar ? { avatar } : {}), }; } @@ -705,6 +827,23 @@ function safeHttpsUrl(value: unknown): string | undefined { return url?.startsWith("https://") ? url : undefined; } +function blueskyMediaProxyPath(value: unknown): string | undefined { + const source = safeBlueskyMediaUrl(value); + return source ? `api/inlays/bluesky-media?url=${encodeURIComponent(source)}` : undefined; +} + +function safeBlueskyMediaUrl(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + try { + const url = new URL(value); + if (url.protocol !== "https:" || url.hostname !== "cdn.bsky.app" || url.port !== "" || url.username || url.password) return undefined; + if (!url.pathname.startsWith("/img/") || url.hash) return undefined; + return url.toString(); + } catch { + return undefined; + } +} + function safeHttpUrl(value: unknown): string | undefined { if (typeof value !== "string") return undefined; try { @@ -719,7 +858,11 @@ function atUriToBlueskyUrl(uri: string): string { const match = uri.match(BLUESKY_POST_URI_PATTERN); if (!match) return uri; const parts = uri.slice(5).split("/"); - return `https://bsky.app/profile/${encodeURIComponent(parts[0]!)}/post/${encodeURIComponent(parts[2]!)}`; + return `https://bsky.app/profile/${blueskyActorPathSegment(parts[0]!)}/post/${encodeURIComponent(parts[2]!)}`; +} + +function blueskyActorPathSegment(value: string): string { + return encodeURIComponent(value).replaceAll("%3A", ":"); } export function renderInspectorHtml(): string { @@ -727,17 +870,25 @@ export function renderInspectorHtml(): string { - - The Stream + + + + + + + + + + Stream -

The Stream

+

Stream

recent activity
Loading recent activity…
`; } @@ -1135,7 +1311,7 @@ function send(response: ServerResponse, status: number, contentType: string, bod response.writeHead(status, { "content-type": contentType, "cache-control": "no-store", - "content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self'; frame-ancestors 'none'; base-uri 'none'", + "content-security-policy": "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; worker-src 'self'; frame-ancestors 'none'; base-uri 'none'", "x-content-type-options": "nosniff", "x-frame-options": "DENY", }); diff --git a/src/web/public-site.ts b/src/web/public-site.ts index 07bba21..5a55189 100644 --- a/src/web/public-site.ts +++ b/src/web/public-site.ts @@ -15,9 +15,22 @@ const PUBLIC_PAGE_FILES = new Map([ ]); const MAX_PUBLIC_FILE_BYTES = 64 * 1024; +const MAX_PUBLIC_FONT_BYTES = 64 * 1024; +const CODE_URL = "https://tangled.org/@cameron.stream/thought-stream"; export async function loadPublicPages(projectRoot: string): Promise> { const publicRoot = path.resolve(projectRoot, "public"); + const fontPath = path.resolve(publicRoot, "assets/around-regular.woff2"); + if (!fontPath.startsWith(`${publicRoot}${path.sep}`)) throw new Error("Public font path escapes the allowlisted root"); + const fontStat = await fs.lstat(fontPath); + if (!fontStat.isFile() || fontStat.isSymbolicLink() || fontStat.size > MAX_PUBLIC_FONT_BYTES) { + throw new Error("Invalid public Around font"); + } + const font = await fs.readFile(fontPath); + if (font.length < 4 || font.subarray(0, 4).toString("ascii") !== "wOF2") { + throw new Error("Invalid public Around font"); + } + const fontDataUrl = `data:font/woff2;base64,${font.toString("base64")}`; const pages = new Map(); for (const [route, relativePath] of PUBLIC_PAGE_FILES) { const filePath = path.resolve(publicRoot, relativePath); @@ -30,7 +43,7 @@ export async function loadPublicPages(projectRoot: string): Promise, pathname: str return pages.get(normalized); } -function renderPage(title: string, markdown: string): string { +function renderPage(route: string, title: string, markdown: string, fontDataUrl: string): string { const body = renderMarkdown(markdown); + if (route === "/") return renderLandingPage(title, body, fontDataUrl); + return renderDocumentationPage(title, body, fontDataUrl); +} + +function renderLandingPage(title: string, body: string, fontDataUrl: string): string { + return ` + + + + + + +${escapeHtml(title)} + + + + +
+
${body}
+ +
+ +`; +} + +function renderDocumentationPage(title: string, body: string, fontDataUrl: string): string { return ` -${escapeHtml(title)} · thought stream - +${escapeHtml(title)} · Stream + -
thought stream
+
Stream
${body}
-
Static public documentation. No live stream data is available from these routes.
+ `; } diff --git a/test/authenticated-proxy.test.ts b/test/authenticated-proxy.test.ts index d836e6c..b56dad1 100644 --- a/test/authenticated-proxy.test.ts +++ b/test/authenticated-proxy.test.ts @@ -127,6 +127,11 @@ describe("authenticated inspector proxy", () => { response.end(""); return; } + if (request.url?.startsWith("/api/inlays/bluesky-media?")) { + response.writeHead(200, { "content-type": "image/webp", "cache-control": "private, max-age=600" }); + response.end(Buffer.concat([Buffer.from("RIFF"), Buffer.alloc(4), Buffer.from("WEBP")])); + return; + } response.writeHead(200, { "content-type": "application/json; charset=utf-8" }); response.end("{}\n"); }); @@ -144,11 +149,20 @@ describe("authenticated inspector proxy", () => { const page = await fetch(`${baseUrl(proxy)}/inspector/`, { headers }); expect(await page.text()).toContain("fetch('api/snapshot')"); expect(page.headers.get("content-security-policy")).toContain("script-src 'unsafe-inline'"); + expect(page.headers.get("content-security-policy")).toContain("img-src 'self' data:"); + expect(page.headers.get("content-security-policy")).toContain("worker-src 'self'"); + expect(page.headers.get("content-security-policy")).not.toContain("img-src 'self' data: https:"); expect(page.headers.get("content-security-policy")).toContain("form-action 'none'"); const api = await fetch(`${baseUrl(proxy)}/inspector/api/snapshot`, { headers }); expect(await api.json()).toEqual({}); expect(api.headers.get("content-security-policy")).toContain("script-src 'none'"); + + const media = await fetch(`${baseUrl(proxy)}/inspector/api/inlays/bluesky-media?url=fixture`, { headers }); + expect(media.status).toBe(200); + expect(media.headers.get("content-type")).toBe("image/webp"); + expect(media.headers.get("cache-control")).toBe("no-store"); + expect(Buffer.from(await media.arrayBuffer()).subarray(0, 4).toString("ascii")).toBe("RIFF"); }); test("serves only allowlisted public pages and never falls through to the private upstream", async () => { @@ -170,8 +184,18 @@ describe("authenticated inspector proxy", () => { const landing = await fetch(base); expect(landing.status).toBe(200); - expect(await landing.text()).toContain("No live stream data"); + const landingHtml = await landing.text(); + expect(landingHtml).toContain("Stream"); + expect(landingHtml).toContain("

Stream

"); + expect(landingHtml).toContain("A private feed for Cameron and the agents working with him."); + expect(landingHtml).toContain('