From 1b145d03ac5c62c97ea96acbcf538575475bd881 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 10:43:03 -0400 Subject: [PATCH 1/8] feat: add hosted wrapper seams --- docs/hosted-wrapping-patches.md | 48 +++++++++++++++++++++++++++++++++ package.json | 10 +++++++ server/app.ts | 30 ++++++++++++++++++--- server/public.ts | 2 +- test/api.test.ts | 16 +++++++++++ tsconfig.build.json | 5 ++-- workers/public.ts | 4 +++ 7 files changed, 108 insertions(+), 7 deletions(-) create mode 100644 docs/hosted-wrapping-patches.md create mode 100644 workers/public.ts diff --git a/docs/hosted-wrapping-patches.md b/docs/hosted-wrapping-patches.md new file mode 100644 index 0000000..7a8e8cf --- /dev/null +++ b/docs/hosted-wrapping-patches.md @@ -0,0 +1,48 @@ +# Hosted wrapping patch notes + +Local branch: `hosted-wrapping-seams` + +These changes are intended as small upstreamable seams for the hosted +`sideshow-cloud` wrapper. No public PR has been opened. + +## Auth hook + +`createApp` now accepts an optional `authenticate(request)` hook in addition to +existing `authToken` behavior. The hook lets an embedding host authorize requests +with edge-signed internal headers while preserving the self-hosted deploy token +path unchanged when the hook is absent. + +## Worker SqlStore export + +A stable `sideshow/workers` package subpath exports `SqlStore` for Cloudflare +Durable Object wrappers. This avoids relying on raw internal source paths. + +## Runtime-agnostic app export + +A stable `sideshow/app` package subpath exports the runtime-agnostic Hono app +without also importing the Node `JsonFileStore`. Worker embedders should prefer +this subpath to keep Node built-ins out of Worker bundles. + +## Viewer and guide assets + +Package exports now include: + +- `sideshow/viewer` -> `viewer/dist/index.html` +- `sideshow/guide/*` -> guide markdown files + +The cloud wrapper currently imports the viewer through the explicit +`sideshow/viewer/dist/index.html` path because Wrangler text rules did not match +the `sideshow/viewer` export when the local file dependency resolved outside the +Worker project root. Published-package behavior should be rechecked before +formalizing the documented import shape. + +## Validation + +Run from this repo: + +```sh +npm test +npm run typecheck +npm run lint +npm run format:check +``` diff --git a/package.json b/package.json index fe86ee0..51f0bc0 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,16 @@ "types": "./dist/server/public.d.ts", "import": "./dist/server/public.js" }, + "./app": { + "types": "./dist/server/app.d.ts", + "import": "./dist/server/app.js" + }, + "./workers": { + "types": "./dist/workers/public.d.ts", + "import": "./dist/workers/public.js" + }, + "./viewer": "./viewer/dist/index.html", + "./guide/*": "./guide/*", "./*": "./*" }, "publishConfig": { diff --git a/server/app.ts b/server/app.ts index 29f5d51..5498f43 100644 --- a/server/app.ts +++ b/server/app.ts @@ -77,15 +77,25 @@ function decodeBase64(b64: string): Uint8Array { // them with the real origin so a deployed instance shows copy-pasteable URLs. const LOCAL_ORIGIN = "http://localhost:8228"; +export type AuthenticateHook = ( + request: Request, +) => boolean | Response | Promise; + export interface AppOptions { store: Store; viewerHtml: string; guideMarkdown: string; setupText: string; agentHowtoText?: string; - // When set (cloud deployments), every route except /guide, /setup, and - // /agent-howto requires it: Authorization bearer, ?key= query, or - // the cookie it sets. + // When set (cloud deployments), this hook authorizes requests before any + // app route runs. Return true to allow, false to use the default 401, or a + // Response for custom denials. This is intentionally lower-level than + // authToken so hosts can validate edge-signed assertions without teaching + // sideshow about their session/token systems. + authenticate?: AuthenticateHook; + // When set (self-hosted Worker deployments), every route except /guide, + // /setup, and /agent-howto requires it: Authorization bearer, ?key= query, + // or the cookie it sets. Preserved for backwards compatibility. authToken?: string; // Update notice: the running version and the upgrade hint that fits this // deployment (npm install vs redeploy). Without `version`, /api/version @@ -197,6 +207,7 @@ export function createApp({ guideMarkdown, setupText, agentHowtoText = setupText, + authenticate, authToken, version, upgradeCommand, @@ -421,8 +432,19 @@ export function createApp({ // --- auth --- app.use("*", async (c, next) => { - if (!authToken) return next(); const path = new URL(c.req.url).pathname; + + if (authenticate) { + const result = await authenticate(c.req.raw); + if (result === true) return next(); + if (result instanceof Response) return result; + if (path.startsWith("/api") || path === "/mcp") { + return c.json({ error: "unauthorized" }, 401); + } + return c.text("unauthorized", 401); + } + + if (!authToken) return next(); if (path === "/guide" || path === "/setup" || path === "/agent-howto") return next(); const bearer = c.req.header("authorization"); diff --git a/server/public.ts b/server/public.ts index 35a6df5..e25beee 100644 --- a/server/public.ts +++ b/server/public.ts @@ -1,6 +1,6 @@ // Stable public server-core entrypoint for integrations that reuse sideshow's // HTTP/SSE/MCP app without depending on the package's internal dist layout. -export { createApp, type AppOptions } from "./app.js"; +export { createApp, type AppOptions, type AuthenticateHook } from "./app.js"; export { JsonFileStore } from "./storage.js"; export type * from "./types.js"; diff --git a/test/api.test.ts b/test/api.test.ts index 6b6d990..441f6f5 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -544,6 +544,22 @@ test("rename session", async () => { assert.equal(((await res.json()) as any).title, "Auth refactor"); }); +test("auth hook can guard an embedding host without authToken", async () => { + const dir = mkdtempSync(join(tmpdir(), "sideshow-test-")); + const app = createApp({ + store: new JsonFileStore(join(dir, "data.json")), + viewerHtml: "viewer", + guideMarkdown: "# guide", + setupText: "# setup", + authenticate: (request) => request.headers.get("x-sideshow-internal") === "ok", + }); + + assert.equal((await app.request("/guide")).status, 401); + assert.equal((await app.request("/api/sessions")).status, 401); + const allowed = await app.request("/api/sessions", { headers: { "x-sideshow-internal": "ok" } }); + assert.equal(allowed.status, 200); +}); + test("auth token guards mutating routes when configured", async () => { const app = makeApp("secret"); const denied = await app.request("/api/snippets", json({ html: "

x

" })); diff --git a/tsconfig.build.json b/tsconfig.build.json index 1c21611..4424e9f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -5,7 +5,8 @@ "outDir": "dist", "rewriteRelativeImportExtensions": true, "declaration": true, - "sourceMap": false + "sourceMap": false, + "types": ["node", "@cloudflare/workers-types/2023-07-01"] }, - "include": ["server", "mcp"] + "include": ["server", "mcp", "workers/sqlStore.ts", "workers/public.ts"] } diff --git a/workers/public.ts b/workers/public.ts new file mode 100644 index 0000000..0365cea --- /dev/null +++ b/workers/public.ts @@ -0,0 +1,4 @@ +// Stable Workers entrypoint for integrations that embed sideshow inside a +// Cloudflare Durable Object and want the SQLite-backed Store implementation. + +export { SqlStore } from "./sqlStore.js"; From 8b3008a65ee624d113675c2ac40b5f0f63101f88 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 16:07:27 -0400 Subject: [PATCH 2/8] Support hosted surface deep links --- viewer/src/App.tsx | 2 +- viewer/src/Card.tsx | 10 +++++++--- viewer/src/api.ts | 14 +++++++++++++- viewer/src/state.ts | 12 +++++++++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx index 9ea1124..76a5d30 100644 --- a/viewer/src/App.tsx +++ b/viewer/src/App.tsx @@ -52,7 +52,7 @@ export default function App() { }); onMount(() => { - refreshSessions(); + refreshSessions(new URLSearchParams(location.search).get("surface")); connect(); checkVersion(); void initTheme(); diff --git a/viewer/src/Card.tsx b/viewer/src/Card.tsx index 62f9b72..7c03d2c 100644 --- a/viewer/src/Card.tsx +++ b/viewer/src/Card.tsx @@ -12,6 +12,7 @@ import { } from "solid-js"; import { api, + appPath, relTime, sessionLabel, type DiffPart as DiffPartData, @@ -21,6 +22,7 @@ import { type Surface, type TerminalPart as TerminalPartData, type TracePart as TracePartData, + surfaceLink, } from "./api.ts"; import { escapeHtml } from "../../server/surfacePage.ts"; import { DiffPart } from "./DiffPart.tsx"; @@ -241,7 +243,9 @@ export function Card(props: { surface: Surface }) { ? `${props.surface.title} (part ${i + 1})` : props.surface.title } - src={`/s/${props.surface.id}?part=${i}&ver=${props.surface.version}&cb=${props.surface.version}&theme=${activeTheme()}`} + src={appPath( + `/s/${props.surface.id}?part=${i}&ver=${props.surface.version}&cb=${props.surface.version}&theme=${activeTheme()}`, + )} > @@ -277,7 +281,7 @@ export function Card(props: { surface: Surface }) { aria-label="Copy link to this surface" onClick={async () => { try { - await navigator.clipboard.writeText(`${location.origin}/s/${props.surface.id}`); + await navigator.clipboard.writeText(surfaceLink(props.surface.id)); toast("Link copied"); } catch { toast("Couldn't copy the link"); @@ -289,7 +293,7 @@ export function Card(props: { surface: Surface }) { diff --git a/viewer/src/api.ts b/viewer/src/api.ts index d0f3a4a..0fcf6c5 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -44,9 +44,21 @@ export interface VersionInfo { notes?: string | null; } +export function appBasePath(): string { + return location.pathname.match(/^\/u\/[^/]+/)?.[0] ?? ""; +} + +export function appPath(path: string): string { + return `${appBasePath()}${path}`; +} + +export function surfaceLink(id: string): string { + return `${location.origin}${appPath(`/?surface=${encodeURIComponent(id)}`)}`; +} + export async function api(path: string, init?: RequestInit): Promise { const res = await fetch( - path, + appPath(path), init ? { headers: { "content-type": "application/json" }, ...init } : undefined, ); if (!res.ok) { diff --git a/viewer/src/state.ts b/viewer/src/state.ts index 0bbc1f7..5867717 100644 --- a/viewer/src/state.ts +++ b/viewer/src/state.ts @@ -146,9 +146,19 @@ export async function refreshSessionsQuiet() { setSessionsInternal(reconcile(await api("/api/sessions"), { key: "id" })); } -export async function refreshSessions() { +export async function refreshSessions(targetSurfaceId?: string | null) { await refreshSessionsQuiet(); if (selected() && !sessions.some((s) => s.id === selected())) setSelectedInternal(null); + if (targetSurfaceId) { + const target = await api(`/api/surfaces/${encodeURIComponent(targetSurfaceId)}`).catch( + () => null, + ); + if (target && sessions.some((s) => s.id === target.sessionId)) { + await select(target.sessionId, { replace: true, initialSurfaceId: target.id }); + return; + } + } + if (!selected() && sessions.length > 0) { // Check the URL first, then localStorage, then fall back to first session. const route = parseRoute(window.location.pathname); From 023b7f4fb9c8223a29cfcce32cb86070f3067d20 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 16:15:10 -0400 Subject: [PATCH 3/8] Add injectable public base path --- server/app.ts | 39 +++++++++++++++++++++++++++++++++++---- server/mcpHttp.ts | 6 ++++-- viewer/src/api.ts | 10 ++++++++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/server/app.ts b/server/app.ts index 5498f43..90442a0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -81,6 +81,8 @@ export type AuthenticateHook = ( request: Request, ) => boolean | Response | Promise; +export type BasePathHook = string | ((request: Request) => string | null | undefined); + export interface AppOptions { store: Store; viewerHtml: string; @@ -97,6 +99,11 @@ export interface AppOptions { // /setup, and /agent-howto requires it: Authorization bearer, ?key= query, // or the cookie it sets. Preserved for backwards compatibility. authToken?: string; + // Public path prefix for deployments mounted below an origin root, e.g. + // /u/:account in a hosted multi-tenant wrapper. The core still receives + // stripped routes like /api/sessions and /s/:id?part=0; this prefix is only + // used when the server/viewer generate browser-visible URLs. + basePath?: BasePathHook; // Update notice: the running version and the upgrade hint that fits this // deployment (npm install vs redeploy). Without `version`, /api/version // reports nothing and the viewer shows no notice. @@ -209,6 +216,7 @@ export function createApp({ agentHowtoText = setupText, authenticate, authToken, + basePath, version, upgradeCommand, fetchLatestRelease, @@ -225,6 +233,14 @@ export function createApp({ return c.json({ error: "internal error" }, 500); }); + const normalizeBasePath = (value: string | null | undefined): string => { + if (!value || value === "/") return ""; + const withLeading = value.startsWith("/") ? value : `/${value}`; + return withLeading.replace(/\/+$/, ""); + }; + const requestBasePath = (request: Request): string => + normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath); + // Cached, fail-silent update lookup: being offline or rate-limited must // cost nothing but the absence of the notice. Failures are cached too, so // a dead network doesn't retry on every viewer load. @@ -472,9 +488,18 @@ export function createApp({ const withOrigin = (text: string, c: { req: { url: string } }) => text.replaceAll(LOCAL_ORIGIN, new URL(c.req.url).origin); - app.get("/", (c) => c.html(withOrigin(viewerHtml, c))); - app.get("/session/:id", (c) => c.html(withOrigin(viewerHtml, c))); - app.get("/session/:id/s/:surfaceId", (c) => c.html(withOrigin(viewerHtml, c))); + const withViewerConfig = (text: string, request: Request) => { + const script = ``; + return text.includes("") + ? text.replace("", `${script}`) + : `${script}${text}`; + }; + + const viewerResponse = (c: { req: { raw: Request; url: string }; html: (html: string) => Response }) => + c.html(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw)); + app.get("/", viewerResponse); + app.get("/session/:id", viewerResponse); + app.get("/session/:id/s/:surfaceId", viewerResponse); app.get("/guide", (c) => c.text(withOrigin(guideMarkdown, c))); app.get("/setup", (c) => c.text(withOrigin(setupText, c))); app.get("/agent-howto", (c) => c.text(withOrigin(agentHowtoText, c))); @@ -741,7 +766,12 @@ export function createApp({ title = old.title; parts = old.parts; } - const idx = Number(c.req.query("part") ?? 0); + const partParam = c.req.query("part"); + const publicBasePath = requestBasePath(c.req.raw); + if (partParam == null && publicBasePath) { + return c.redirect(`${publicBasePath}/?surface=${encodeURIComponent(surface.id)}`, 302); + } + const idx = Number(partParam ?? 0); const part = parts[idx]; if (!part || part.kind !== "html") return c.text("No html part at that index", 404); c.header("X-Content-Type-Options", "nosniff"); @@ -870,6 +900,7 @@ export function createApp({ registerMcp(app, { store, + basePath: requestBasePath, publishSurface, reviseSurface, createComment, diff --git a/server/mcpHttp.ts b/server/mcpHttp.ts index b395df7..5a80160 100644 --- a/server/mcpHttp.ts +++ b/server/mcpHttp.ts @@ -22,6 +22,7 @@ type FlowResult = Promise< export interface McpDeps { store: Store; + basePath?: (request: Request) => string; publishSurface(input: { parts: SurfacePart[]; title?: string; @@ -231,9 +232,10 @@ export function registerMcp(app: Hono, deps: McpDeps) { if (msg.method === "ping") return rpc(msg.id, {}); if (msg.method === "tools/list") return rpc(msg.id, { tools: HTTP_MCP_TOOLS }); if (msg.method === "tools/call") { - const origin = new URL(c.req.url).origin; + const url = new URL(c.req.url); + const baseUrl = `${url.origin}${deps.basePath?.(c.req.raw) ?? ""}`; try { - const text = await callTool(msg.params?.name, msg.params?.arguments ?? {}, origin); + const text = await callTool(msg.params?.name, msg.params?.arguments ?? {}, baseUrl); return rpc(msg.id, { content: [{ type: "text", text }] }); } catch (err) { return rpc(msg.id, { diff --git a/viewer/src/api.ts b/viewer/src/api.ts index 0fcf6c5..856c925 100644 --- a/viewer/src/api.ts +++ b/viewer/src/api.ts @@ -44,8 +44,14 @@ export interface VersionInfo { notes?: string | null; } +declare global { + interface Window { + __SIDESHOW_BASE_PATH__?: string; + } +} + export function appBasePath(): string { - return location.pathname.match(/^\/u\/[^/]+/)?.[0] ?? ""; + return window.__SIDESHOW_BASE_PATH__ ?? location.pathname.match(/^\/u\/[^/]+/)?.[0] ?? ""; } export function appPath(path: string): string { @@ -53,7 +59,7 @@ export function appPath(path: string): string { } export function surfaceLink(id: string): string { - return `${location.origin}${appPath(`/?surface=${encodeURIComponent(id)}`)}`; + return `${location.origin}${appPath(`/s/${encodeURIComponent(id)}`)}`; } export async function api(path: string, init?: RequestInit): Promise { From 4ae732f87e7838c791c9b42cf83875472af330f7 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 16:32:47 -0400 Subject: [PATCH 4/8] Avoid regex when normalizing base path --- server/app.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/server/app.ts b/server/app.ts index 90442a0..4460f81 100644 --- a/server/app.ts +++ b/server/app.ts @@ -236,7 +236,9 @@ export function createApp({ const normalizeBasePath = (value: string | null | undefined): string => { if (!value || value === "/") return ""; const withLeading = value.startsWith("/") ? value : `/${value}`; - return withLeading.replace(/\/+$/, ""); + let end = withLeading.length; + while (end > 0 && withLeading.charCodeAt(end - 1) === 47) end--; + return withLeading.slice(0, end); }; const requestBasePath = (request: Request): string => normalizeBasePath(typeof basePath === "function" ? basePath(request) : basePath); @@ -495,8 +497,10 @@ export function createApp({ : `${script}${text}`; }; - const viewerResponse = (c: { req: { raw: Request; url: string }; html: (html: string) => Response }) => - c.html(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw)); + const viewerResponse = (c: { + req: { raw: Request; url: string }; + html: (html: string) => Response; + }) => c.html(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw)); app.get("/", viewerResponse); app.get("/session/:id", viewerResponse); app.get("/session/:id/s/:surfaceId", viewerResponse); From d9de1aedf316f1dea2e5301035ad82bde3869f6e Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 16:36:00 -0400 Subject: [PATCH 5/8] Fix viewer route handler typing --- server/app.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/server/app.ts b/server/app.ts index 4460f81..0a31cb0 100644 --- a/server/app.ts +++ b/server/app.ts @@ -497,13 +497,11 @@ export function createApp({ : `${script}${text}`; }; - const viewerResponse = (c: { - req: { raw: Request; url: string }; - html: (html: string) => Response; - }) => c.html(withViewerConfig(withOrigin(viewerHtml, c), c.req.raw)); - app.get("/", viewerResponse); - app.get("/session/:id", viewerResponse); - app.get("/session/:id/s/:surfaceId", viewerResponse); + const configuredViewerHtml = (request: Request, url: string) => + withViewerConfig(withOrigin(viewerHtml, { req: { url } }), request); + app.get("/", (c) => c.html(configuredViewerHtml(c.req.raw, c.req.url))); + app.get("/session/:id", (c) => c.html(configuredViewerHtml(c.req.raw, c.req.url))); + app.get("/session/:id/s/:surfaceId", (c) => c.html(configuredViewerHtml(c.req.raw, c.req.url))); app.get("/guide", (c) => c.text(withOrigin(guideMarkdown, c))); app.get("/setup", (c) => c.text(withOrigin(setupText, c))); app.get("/agent-howto", (c) => c.text(withOrigin(agentHowtoText, c))); From 350396d8d81b2c563d67ac873114773209ffda34 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 16:41:52 -0400 Subject: [PATCH 6/8] Add changeset for hosted wrapper seams --- .changeset/hosted-base-path.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/hosted-base-path.md diff --git a/.changeset/hosted-base-path.md b/.changeset/hosted-base-path.md new file mode 100644 index 0000000..5dbd2de --- /dev/null +++ b/.changeset/hosted-base-path.md @@ -0,0 +1,5 @@ +--- +"sideshow": minor +--- + +Add hosted wrapper seams, including injectable public base-path support for deployments mounted below an origin root while preserving default self-hosted routes. From 5d47457344560ab4d2359dc4c8c7025e5391c642 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 17:09:10 -0400 Subject: [PATCH 7/8] Inject viewer config at document head --- server/app.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/app.ts b/server/app.ts index 0a31cb0..0a78b9a 100644 --- a/server/app.ts +++ b/server/app.ts @@ -492,8 +492,9 @@ export function createApp({ const withViewerConfig = (text: string, request: Request) => { const script = ``; - return text.includes("") - ? text.replace("", `${script}`) + const headClose = text.lastIndexOf(""); + return headClose >= 0 + ? `${text.slice(0, headClose)}${script}${text.slice(headClose)}` : `${script}${text}`; }; From f608b2e5b2a6ea3ce3e49ba132ac193e090cb927 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 20 Jun 2026 17:11:37 -0400 Subject: [PATCH 8/8] Build package outputs for git installs --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 51f0bc0..7f5127c 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "deploy": "npm run build:viewer && wrangler deploy", "dev:cloud": "npm run build:viewer && wrangler dev", "prepack": "npm run build", - "prepare": "simple-git-hooks && npm run build:viewer", + "prepare": "simple-git-hooks && npm run build", "security:audit": "npm audit --audit-level=high" }, "dependencies": {