Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 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/import-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"sideshow": minor
---

Add authenticated `/api/export` and `/api/import` endpoints for moving boards between stores, including sessions, surfaces, comments, settings, and base64-encoded assets.
16 changes: 16 additions & 0 deletions docs/deploying.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,22 @@ To share read-only access without handing out the token, set
Writes still require `SIDESHOW_TOKEN`, and authenticated owners keep the full
UI. Invalid `SIDESHOW_PUBLIC_READ` values are ignored.

## Backup and migration

Authenticated owners can export the whole board as JSON and import it into
another sideshow instance. The payload preserves IDs and includes sessions,
surfaces, comments, settings, and base64-encoded assets.

```sh
curl -H "Authorization: Bearer $SIDESHOW_TOKEN" \
"$SIDESHOW_URL/api/export" > sideshow-backup.json

curl -X POST -H "Authorization: Bearer $SIDESHOW_TOKEN" \
-H "Content-Type: application/json" \
--data-binary @sideshow-backup.json \
"$SIDESHOW_URL/api/import"
```

Remote agents can connect MCP straight to the deployment:

```sh
Expand Down
49 changes: 48 additions & 1 deletion server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,13 +66,22 @@ function inferAssetKind(contentType: string): AssetKind {

const isAssetKind = (v: unknown): v is AssetKind => v === "image" || v === "trace" || v === "file";

// base64 -> bytes, runtime-agnostic (atob is a global in Node and Workers).
// base64 <-> bytes, runtime-agnostic (atob/btoa are globals in Node and Workers).
function decodeBase64(b64: string): Uint8Array {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}

function encodeBase64(data: Uint8Array): string {
let bin = "";
const chunk = 0x8000;
for (let i = 0; i < data.length; i += chunk) {
bin += String.fromCharCode(...data.subarray(i, i + chunk));
}
return btoa(bin);
}
// Docs and onboarding snippets are written against the local default; serve
// them with the real origin so a deployed instance shows copy-pasteable URLs.
const LOCAL_ORIGIN = "http://localhost:8228";
Expand Down Expand Up @@ -589,6 +598,44 @@ export function createApp({

// --- sessions ---

// Bulk import: accepts sessions, surfaces, comments, assets, and settings
// with original ids preserved. Idempotent — existing ids are skipped, so the
// endpoint is safe to call repeatedly with overlapping data.
app.post("/api/import", async (c) => {
if (!isAuthenticated(c)) return c.json({ error: "unauthorized" }, 401);
const body = await c.req.json().catch(() => null);
if (!body) return c.json({ error: "invalid JSON body" }, 400);
await store.importData(body);
return c.json({
ok: true,
sessions: body.sessions?.length ?? 0,
surfaces: body.surfaces?.length ?? 0,
comments: body.comments?.length ?? 0,
assets: body.assets?.length ?? 0,
});
});

// Bulk export: returns sessions, surfaces, comments, assets, and settings —
// the same shape /api/import accepts, so round-tripping is trivial.
app.get("/api/export", async (c) => {
if (!isAuthenticated(c)) return c.json({ error: "unauthorized" }, 401);
const [sessions, surfaces, comments, settings] = await Promise.all([
store.listSessions(),
store.listSurfaces(),
store.listComments({}),
store.listSettings(),
]);
const assets = [];
for (const session of sessions) {
for (const meta of await store.listAssets(session.id)) {
const asset = await store.getAsset(meta.id);
if (!asset) continue;
assets.push({ ...asset, data: encodeBase64(asset.data) });
}
}
return c.json({ sessions, surfaces, comments, assets, settings });
});

app.get("/api/sessions", async (c) => {
const [sessions, surfaces] = await Promise.all([store.listSessions(), store.listSurfaces()]);
const counts = new Map<string, number>();
Expand Down
34 changes: 34 additions & 0 deletions server/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
hashAssetId,
HISTORY_LIMIT,
htmlPart,
type ImportData,
MAX_BOARD_ASSET_BYTES,
newId,
selectEvictions,
Expand Down Expand Up @@ -266,6 +267,11 @@ export class JsonFileStore implements Store {
await this.persist();
}

async listSettings() {
await this.load();
return Object.fromEntries(this.settings);
}

// --- surfaces ---

async listSurfaces(sessionId?: string) {
Expand Down Expand Up @@ -460,4 +466,32 @@ export class JsonFileStore implements Store {
await this.load();
return this.referencedAssetIds().has(id);
}

async importData(data: ImportData) {
await this.load();
for (const s of data.sessions ?? []) {
if (!this.sessions.has(s.id)) this.sessions.set(s.id, clone(s));
}
for (const s of data.surfaces ?? []) {
if (!this.surfaces.has(s.id)) this.surfaces.set(s.id, clone(s));
}
for (const c of data.comments ?? []) {
if (!this.comments.some((x) => x.id === c.id || x.seq === c.seq)) {
this.comments.push(clone(c));
if (c.seq > this.lastSeq) this.lastSeq = c.seq;
}
}
for (const a of data.assets ?? []) {
if (!this.assets.has(a.id)) {
this.assets.set(a.id, {
...a,
data: new Uint8Array(Buffer.from(a.data, "base64")),
});
}
}
for (const [key, value] of Object.entries(data.settings ?? {})) {
this.settings.set(key, value);
}
await this.persist();
}
}
16 changes: 16 additions & 0 deletions server/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ export interface Store {
// for an unset key.
getSetting(key: string): Promise<string | null>;
setSetting(key: string, value: string): Promise<void>;
listSettings(): Promise<Record<string, string>>;

listSurfaces(sessionId?: string): Promise<Surface[]>;
getSurface(id: string): Promise<Surface | null>;
Expand Down Expand Up @@ -254,6 +255,21 @@ export interface Store {
// Whether any live surface (current or historical version) references this
// asset id. Drives the optimistic-read wait and reference-aware deletion.
isAssetReferenced(id: string): Promise<boolean>;

// Bulk import: insert sessions, surfaces, comments, assets, and settings with
// their original ids preserved. Used for one-time migration between stores.
// Skips entities whose ids already exist.
importData(data: ImportData): Promise<void>;
}

export type ImportAsset = Omit<Asset, "data"> & { data: string };

export interface ImportData {
sessions?: Session[];
surfaces?: Surface[];
comments?: Comment[];
assets?: ImportAsset[];
settings?: Record<string, string>;
}

export const HISTORY_LIMIT = 20;
Expand Down
218 changes: 218 additions & 0 deletions test/importExport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "node:test";
import { createApp } from "../server/app.ts";
import { JsonFileStore } from "../server/storage.ts";

function makeApp(authToken?: string) {
const dir = mkdtempSync(join(tmpdir(), "sideshow-import-export-test-"));
const store = new JsonFileStore(join(dir, "data.json"));
const app = createApp({
store,
viewerHtml: "<html>viewer</html>",
guideMarkdown: "# guide",
setupText: "# setup",
agentHowtoText: "# agent how-to",
authToken,
});
return { app, store };
}

const b64 = (bytes: number[]) => Buffer.from(new Uint8Array(bytes)).toString("base64");

const json = (body: unknown) => ({
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});

const authedJson = (body: unknown, token = "secret") => ({
...json(body),
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
});

function normalizeExport(data: any) {
return {
sessions: [...data.sessions].sort((a, b) => a.id.localeCompare(b.id)),
surfaces: [...data.surfaces].sort((a, b) => a.id.localeCompare(b.id)),
comments: [...data.comments].sort((a, b) => a.seq - b.seq),
assets: [...data.assets].sort((a, b) => a.id.localeCompare(b.id)),
settings: data.settings,
};
}

test("import/export endpoints require auth when configured", async () => {
const { app } = makeApp("secret");

assert.equal((await app.request("/api/export")).status, 401);
assert.equal((await app.request("/api/import", json({ sessions: [] }))).status, 401);

assert.equal(
(await app.request("/api/export", { headers: { authorization: "Bearer secret" } })).status,
200,
);
assert.equal((await app.request("/api/import", authedJson({ sessions: [] }))).status, 200);
});

test("POST /api/import rejects invalid JSON", async () => {
const { app } = makeApp();
const res = await app.request("/api/import", {
method: "POST",
headers: { "content-type": "application/json" },
body: "{not json",
});
assert.equal(res.status, 400);
});

test("GET /api/export returns sessions, surfaces, comments, assets, and all settings", async () => {
const { app, store } = makeApp();
const created = (await (
await app.request("/api/snippets", json({ html: "<p>x</p>", title: "Sketch", agent: "pi" }))
).json()) as any;
await app.request(
"/api/comments",
json({ snippet: created.id, text: "ship it", author: "user" }),
);
const asset = (await (
await app.request(
"/api/assets",
json({
session: created.sessionId,
data: b64([1, 2, 3]),
contentType: "image/png",
filename: "shot.png",
}),
)
).json()) as any;
await store.setSetting("theme", "gruvbox");
await store.setSetting("sidebar", "collapsed");

const exported = (await (await app.request("/api/export")).json()) as any;

assert.equal(exported.sessions.length, 1);
assert.equal(exported.sessions[0].id, created.sessionId);
assert.equal(exported.surfaces.length, 1);
assert.equal(exported.surfaces[0].id, created.id);
assert.equal(exported.comments.length, 1);
assert.equal(exported.comments[0].text, "ship it");
assert.deepEqual(exported.settings, { theme: "gruvbox", sidebar: "collapsed" });
assert.equal(exported.assets.length, 1);
assert.deepEqual(exported.assets[0], {
id: asset.id,
sessionId: created.sessionId,
kind: "image",
contentType: "image/png",
byteLength: 3,
filename: "shot.png",
data: b64([1, 2, 3]),
createdAt: exported.assets[0].createdAt,
lastAccessedAt: exported.assets[0].lastAccessedAt,
});
});

test("POST /api/import preserves IDs and imported assets are served", async () => {
const { app } = makeApp();
const imported = {
sessions: [
{
id: "known-session",
agent: "pi",
title: "Imported session",
cwd: "/repo",
createdAt: "2026-06-21T00:00:00.000Z",
lastActiveAt: "2026-06-21T00:00:01.000Z",
agentSeq: 0,
},
],
surfaces: [
{
id: "known-surface",
sessionId: "known-session",
title: "Imported surface",
parts: [{ kind: "image", assetId: "known-asset", alt: "blob" }],
createdAt: "2026-06-21T00:00:02.000Z",
updatedAt: "2026-06-21T00:00:02.000Z",
version: 1,
history: [],
},
],
comments: [
{
id: "known-comment",
seq: 99,
sessionId: "known-session",
surfaceId: "known-surface",
surfaceTitle: "Imported surface",
author: "user",
text: "hello import",
createdAt: "2026-06-21T00:00:03.000Z",
},
],
assets: [
{
id: "known-asset",
sessionId: "known-session",
kind: "file",
contentType: "application/octet-stream",
byteLength: 4,
filename: "blob.bin",
data: b64([5, 6, 7, 8]),
createdAt: "2026-06-21T00:00:04.000Z",
lastAccessedAt: "2026-06-21T00:00:05.000Z",
},
],
settings: { theme: "gruvbox" },
};

const res = await app.request("/api/import", json(imported));
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), {
ok: true,
sessions: 1,
surfaces: 1,
comments: 1,
assets: 1,
});

const sessions = (await (await app.request("/api/sessions")).json()) as any[];
assert.equal(sessions[0].id, "known-session");
const surface = (await (await app.request("/api/surfaces/known-surface")).json()) as any;
assert.equal(surface.id, "known-surface");
assert.equal(surface.parts[0].assetId, "known-asset");
const comments = (await (await app.request("/api/comments?session=known-session")).json()) as any;
assert.equal(comments.comments[0].id, "known-comment");
assert.equal(comments.comments[0].seq, 99);
assert.equal(((await (await app.request("/api/theme")).json()) as any).id, "gruvbox");
const asset = await app.request("/a/known-asset");
assert.equal(asset.status, 200);
assert.deepEqual([...new Uint8Array(await asset.arrayBuffer())], [5, 6, 7, 8]);
});

test("exported data can be imported into another server without changing shape", async () => {
const source = makeApp();
const created = (await (
await source.app.request(
"/api/snippets",
json({ html: "<p>round trip</p>", title: "Round trip", agent: "pi" }),
)
).json()) as any;
await source.app.request(
"/api/assets",
json({ session: created.sessionId, data: b64([9, 8, 7]), contentType: "text/plain" }),
);
await source.app.request(
"/api/comments",
json({ snippet: created.id, text: "round-trip comment", author: "user" }),
);
await source.store.setSetting("theme", "one");
await source.store.setSetting("sidebar", "expanded");

const exported = (await (await source.app.request("/api/export")).json()) as any;
const target = makeApp();
assert.equal((await target.app.request("/api/import", json(exported))).status, 200);
const reexported = (await (await target.app.request("/api/export")).json()) as any;

assert.deepEqual(normalizeExport(reexported), normalizeExport(exported));
});
Loading
Loading