Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hosted-base-path.md
Original file line number Diff line number Diff line change
@@ -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.
48 changes: 48 additions & 0 deletions docs/hosted-wrapping-patches.md
Original file line number Diff line number Diff line change
@@ -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
```
12 changes: 11 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand All @@ -63,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": {
Expand Down
72 changes: 64 additions & 8 deletions server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,16 +77,33 @@ 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<boolean | Response>;

export type BasePathHook = string | ((request: Request) => string | null | undefined);

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;
// 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.
Expand Down Expand Up @@ -197,7 +214,9 @@ export function createApp({
guideMarkdown,
setupText,
agentHowtoText = setupText,
authenticate,
authToken,
basePath,
version,
upgradeCommand,
fetchLatestRelease,
Expand All @@ -214,6 +233,16 @@ 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}`;
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);

// 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.
Expand Down Expand Up @@ -421,8 +450,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");
Expand Down Expand Up @@ -450,9 +490,19 @@ 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 = `<script>window.__SIDESHOW_BASE_PATH__=${JSON.stringify(requestBasePath(request))};</script>`;
const headClose = text.lastIndexOf("</head>");
return headClose >= 0
? `${text.slice(0, headClose)}${script}${text.slice(headClose)}`
: `${script}${text}`;
};

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)));
Expand Down Expand Up @@ -719,7 +769,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");
Expand Down Expand Up @@ -848,6 +903,7 @@ export function createApp({

registerMcp(app, {
store,
basePath: requestBasePath,
publishSurface,
reviseSurface,
createComment,
Expand Down
6 changes: 4 additions & 2 deletions server/mcpHttp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ type FlowResult<T> = Promise<

export interface McpDeps {
store: Store;
basePath?: (request: Request) => string;
publishSurface(input: {
parts: SurfacePart[];
title?: string;
Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 1 addition & 1 deletion server/public.ts
Original file line number Diff line number Diff line change
@@ -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";
16 changes: 16 additions & 0 deletions test/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<html>viewer</html>",
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: "<p>x</p>" }));
Expand Down
5 changes: 3 additions & 2 deletions tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
2 changes: 1 addition & 1 deletion viewer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export default function App() {
});

onMount(() => {
refreshSessions();
refreshSessions(new URLSearchParams(location.search).get("surface"));
connect();
checkVersion();
void initTheme();
Expand Down
10 changes: 7 additions & 3 deletions viewer/src/Card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from "solid-js";
import {
api,
appPath,
relTime,
sessionLabel,
type DiffPart as DiffPartData,
Expand All @@ -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";
Expand Down Expand Up @@ -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()}`,
)}
></iframe>
</Match>
<Match when={part().kind === "markdown"}>
Expand Down Expand Up @@ -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");
Expand All @@ -289,7 +293,7 @@ export function Card(props: { surface: Surface }) {
<a
class="act icon open"
target="_blank"
href={`/s/${props.surface.id}`}
href={surfaceLink(props.surface.id)}
title="Open in a new tab"
aria-label="Open in a new tab"
>
Expand Down
20 changes: 19 additions & 1 deletion viewer/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,27 @@ export interface VersionInfo {
notes?: string | null;
}

declare global {
interface Window {
__SIDESHOW_BASE_PATH__?: string;
}
}

export function appBasePath(): string {
return window.__SIDESHOW_BASE_PATH__ ?? location.pathname.match(/^\/u\/[^/]+/)?.[0] ?? "";
}

export function appPath(path: string): string {
return `${appBasePath()}${path}`;
}

export function surfaceLink(id: string): string {
return `${location.origin}${appPath(`/s/${encodeURIComponent(id)}`)}`;
}

export async function api<T = unknown>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(
path,
appPath(path),
init ? { headers: { "content-type": "application/json" }, ...init } : undefined,
);
if (!res.ok) {
Expand Down
12 changes: 11 additions & 1 deletion viewer/src/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,19 @@ export async function refreshSessionsQuiet() {
setSessionsInternal(reconcile(await api<SessionRow[]>("/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<Surface>(`/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);
Expand Down
4 changes: 4 additions & 0 deletions workers/public.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading