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
27 changes: 13 additions & 14 deletions packages/cli/src/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@ import { EXIT_NO_VIEWER, isConnError, missingConfigMessage, unreachableMessage }
// board you like (decoupled from its data, with AI-facing `hints`) as a global template addressed by
// id, then in a FUTURE session `template get <id>`, read the hints, author fresh data, and `push`.
//
// `save` posts to the wsid-scoped `${base}/template` (it reads that board's content); `list`/`get`/
// `delete` hit the workspace-INDEPENDENT origin routes (`/templates`, `/template/<id>`), so they're
// derived from the URL's origin rather than the `/w/<wsid>` base.
// All subcommands hit the wsid-scoped `${base}` (`…/w/<wsid>`): `save` posts to `${base}/template`
// (reads that board's content), and `list`/`get`/`delete` use `${base}/templates` and
// `${base}/template/<id>`. The template LIBRARY is global (any workspace sees every template), but the
// routes are workspace-scoped so the unguessable wsid gates access (the server rejects them otherwise).

export interface TemplateDeps {
env: Record<string, string | undefined>;
Expand Down Expand Up @@ -44,15 +45,13 @@ export async function template(argv: string[], deps: TemplateDeps): Promise<numb
return 3;
}
if (!base) { process.stderr.write(missingConfigMessage(false)); return EXIT_NO_VIEWER; }
let origin: string;
try { origin = new URL(base).origin; } catch { process.stderr.write(`invalid TERMCHART_VIEWER_URL: ${base}\n`); return 3; }
const headers: Record<string, string> = token ? { authorization: `Bearer ${token}` } : {};

try {
if (sub === "save") return await save(rest, base, headers);
if (sub === "list") return await listTemplates(rest, origin, headers);
if (sub === "get") return await getTemplate(rest, origin, headers);
return await deleteTemplate(rest, origin, headers);
if (sub === "list") return await listTemplates(rest, base, headers);
if (sub === "get") return await getTemplate(rest, base, headers);
return await deleteTemplate(rest, base, headers);
} catch (e) {
if (isConnError(e)) { process.stderr.write(unreachableMessage(base, e)); return EXIT_NO_VIEWER; }
process.stderr.write(`template ${sub} failed: ${(e as Error).message}\n`);
Expand Down Expand Up @@ -111,10 +110,10 @@ function parseRead(argv: string[]): ReadArgs {
return a;
}

async function listTemplates(argv: string[], origin: string, headers: Record<string, string>): Promise<number> {
async function listTemplates(argv: string[], base: string, headers: Record<string, string>): Promise<number> {
const a = parseRead(argv);
if (a.error) { process.stderr.write(a.error + "\n"); return 3; }
const r = await fetch(`${origin}/templates`, { method: "GET", headers });
const r = await fetch(`${base}/templates`, { method: "GET", headers });
if (!r.ok) { process.stderr.write(`template list failed: HTTP ${r.status}\n`); return 1; }
const items = (await r.json()) as TemplateSummary[];
if (a.json) { process.stdout.write(JSON.stringify(items, null, 2) + "\n"); return 0; }
Expand All @@ -124,11 +123,11 @@ async function listTemplates(argv: string[], origin: string, headers: Record<str
return 0;
}

async function getTemplate(argv: string[], origin: string, headers: Record<string, string>): Promise<number> {
async function getTemplate(argv: string[], base: string, headers: Record<string, string>): Promise<number> {
const a = parseRead(argv);
if (a.error) { process.stderr.write(a.error + "\n"); return 3; }
if (!a.id) { process.stderr.write("template get needs a template id\n"); return 3; }
const r = await fetch(`${origin}/template/${encodeURIComponent(a.id)}`, { method: "GET", headers });
const r = await fetch(`${base}/template/${encodeURIComponent(a.id)}`, { method: "GET", headers });
if (r.status === 404) { process.stderr.write(`no such template: ${a.id}\n`); return 1; }
if (!r.ok) { process.stderr.write(`template get failed: HTTP ${r.status}\n`); return 1; }
const t = (await r.json()) as Template;
Expand All @@ -138,11 +137,11 @@ async function getTemplate(argv: string[], origin: string, headers: Record<strin
return 0;
}

async function deleteTemplate(argv: string[], origin: string, headers: Record<string, string>): Promise<number> {
async function deleteTemplate(argv: string[], base: string, headers: Record<string, string>): Promise<number> {
const a = parseRead(argv);
if (a.error) { process.stderr.write(a.error + "\n"); return 3; }
if (!a.id) { process.stderr.write("template delete needs a template id\n"); return 3; }
const r = await fetch(`${origin}/template/${encodeURIComponent(a.id)}`, { method: "DELETE", headers });
const r = await fetch(`${base}/template/${encodeURIComponent(a.id)}`, { method: "DELETE", headers });
if (r.status === 404) { process.stderr.write(`no such template: ${a.id}\n`); return 1; }
if (!r.ok) { process.stderr.write(`template delete failed: HTTP ${r.status}\n`); return 1; }
process.stdout.write(`deleted ${a.id}\n`);
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/test/template.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe("template", () => {
const code = await template(["list"], { env: env(url) });
const text = cap.done();
expect(code).toBe(0);
expect(reqs[0].path).toBe("/templates"); // origin-rooted, NOT /w/ws1/templates
expect(reqs[0].path).toBe("/w/ws1/templates"); // wsid-scoped (the gate)
expect(text).toContain("tpl_1");
expect(text).toContain("[datatable]");
expect(text).toContain("Spend");
Expand All @@ -72,7 +72,7 @@ describe("template", () => {
const code = await template(["get", "tpl_1"], { env: env(url) });
const text = cap.done();
expect(code).toBe(0);
expect(reqs[0].path).toBe("/template/tpl_1");
expect(reqs[0].path).toBe("/w/ws1/template/tpl_1");
expect(text.indexOf("ADAPT: swap rows")).toBeLessThan(text.indexOf('"columns"')); // hints first, then content
});

Expand All @@ -97,7 +97,7 @@ describe("template", () => {
const code = await template(["delete", "tpl_1"], { env: env(url) });
expect(code).toBe(0);
expect(reqs[0].method).toBe("DELETE");
expect(reqs[0].path).toBe("/template/tpl_1");
expect(reqs[0].path).toBe("/w/ws1/template/tpl_1");
expect(cap.done()).toContain("deleted tpl_1");
});

Expand Down
16 changes: 11 additions & 5 deletions packages/viewer/e2e/template.e2e.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,12 @@ try {
ok("UI: hints expand inline", /DATA SLOTS:/.test(await p.locator(".tpl-hints-box").first().textContent()));

// Global HTTP API: list + get (hints + content).
const list = await (await fetch(`${BASE}/templates`)).json();
const list = await (await fetch(`${BASE}/w/${WSID}/templates`)).json();
ok("GET /templates lists the template", Array.isArray(list) && list.length === 1 && list[0].type === "datatable");
const id = list[0].id;
const full = await (await fetch(`${BASE}/template/${id}`)).json();
const full = await (await fetch(`${BASE}/w/${WSID}/template/${id}`)).json();
ok("GET /template/<id> returns hints + example content", full.hints.includes("WHAT:") && full.content.length > 10 && full.type === "datatable");
ok("GET /template/<unknown> is 404", (await fetch(`${BASE}/template/tpl_missing`)).status === 404);
ok("GET /template/<unknown> is 404", (await fetch(`${BASE}/w/${WSID}/template/tpl_missing`)).status === 404);

// Reuse: adapt the example (swap a row) and push it to a NEW scope — proves the loop.
const adapted = JSON.parse(full.content);
Expand All @@ -90,9 +90,15 @@ try {
// Reserved store wsid is never servable.
ok("reserved __templates__ wsid is not servable (404)", (await fetch(`${BASE}/w/__templates__/state`)).status === 404);

// Auth scoping (regression guard): the library routes are wsid-gated, NOT origin-rooted, and the
// read-only demo is excluded — so a caller with no workspace can't enumerate or delete the library.
ok("origin-rooted GET /templates is gone (404, not an open catalog)", (await fetch(`${BASE}/templates`)).status === 404);
ok("origin-rooted DELETE /template/<id> is gone (not 204)", (await fetch(`${BASE}/template/${id}`, { method: "DELETE" })).status !== 204);
ok("demo cannot enumerate the library (GET /w/demo/templates -> 404)", (await fetch(`${BASE}/w/demo/templates`)).status === 404);

// Delete via the API, then the library is empty.
ok("DELETE /template/<id> succeeds", (await fetch(`${BASE}/template/${id}`, { method: "DELETE" })).status === 204);
ok("after delete, GET /templates is empty", (await (await fetch(`${BASE}/templates`)).json()).length === 0);
ok("DELETE /template/<id> succeeds", (await fetch(`${BASE}/w/${WSID}/template/${id}`, { method: "DELETE" })).status === 204);
ok("after delete, GET /templates is empty", (await (await fetch(`${BASE}/w/${WSID}/templates`)).json()).length === 0);

ok("no console/page errors", errors.length === 0 || (console.log(" errors:", errors.slice(0, 5)), false));
} catch (e) {
Expand Down
6 changes: 3 additions & 3 deletions packages/viewer/src/client/viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise<void> {
wrap.replaceChildren();
let items: TemplateSummary[] = [];
try {
const r = await fetch(`/templates`);
const r = await fetch(`${apiBase}/templates`);
if (r.ok) items = (await r.json()) as TemplateSummary[];
} catch { /* leave empty */ }
if (!items.length) {
Expand Down Expand Up @@ -875,7 +875,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise<void> {
del.onclick = async () => {
const ok = await confirmModal({ title: "Delete template", body: `Permanently remove "${t.name}" (${t.id}) from the template library?`, confirmLabel: "Delete", danger: true });
if (!ok) return;
try { await fetch(`/template/${encodeURIComponent(t.id)}`, { method: "DELETE" }); } catch { /* best-effort */ }
try { await fetch(`${apiBase}/template/${encodeURIComponent(t.id)}`, { method: "DELETE" }); } catch { /* best-effort */ }
await renderTemplateList(wrap);
};
acts.append(copyId, hintsBtn, del);
Expand All @@ -888,7 +888,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise<void> {
if (!hintsBox.dataset.loaded) {
hintsBox.textContent = "Loading…";
try {
const r = await fetch(`/template/${encodeURIComponent(t.id)}`);
const r = await fetch(`${apiBase}/template/${encodeURIComponent(t.id)}`);
const full = r.ok ? (await r.json()) as { hints?: string } : null;
hintsBox.textContent = (full?.hints || "(no hints provided)").trim() || "(no hints provided)";
} catch { hintsBox.textContent = "(failed to load hints)"; }
Expand Down
57 changes: 29 additions & 28 deletions packages/viewer/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,31 +368,6 @@ export function createViewerServer(opts: ServerOpts) {
return handleShareView(req, res, parts[1], parts[2] ?? "");
}

// Global template library (reusable diagram templates addressed by id across independent/future
// sessions). Workspace-independent: GET /templates (catalog) and GET/DELETE /template/<id>.
// Creating a template lives under /w/<wsid>/template (it reads a board's content). The reserved
// __templates__ store wsid is never servable (guarded below).
if (parts[0] === "templates" && parts.length === 1 && req.method === "GET") {
await hydratedForRead(TEMPLATES_WSID);
return send(res, 200, JSON.stringify(templateStore.list()), "application/json");
}
if (parts[0] === "template" && parts[1]) {
const id = parts[1];
if (req.method === "GET") {
await hydratedForRead(TEMPLATES_WSID);
const t = templateStore.get(id);
return t ? send(res, 200, JSON.stringify(t), "application/json") : send(res, 404, "no such template");
}
if (req.method === "DELETE") {
// Tokenless (same posture as clear/share — the UI holds no push token); the unguessable id gates it.
if (!(await hydratedForWrite(TEMPLATES_WSID))) return send(res, 503, "state backend unavailable; refusing to modify to avoid data loss");
const ok = templateStore.remove(id);
if (ok) await persistSave(TEMPLATES_WSID);
return ok ? send(res, 204) : send(res, 404, "no such template");
}
return send(res, 405, "method not allowed");
}

if (parts[0] !== "w" || !parts[1]) return send(res, 404, "not found");
const wsid = parts[1];
if (wsid === SHARES_WSID || wsid === TEMPLATES_WSID) return send(res, 404, "not found"); // reserved store wsids, never servable
Expand Down Expand Up @@ -493,9 +468,35 @@ export function createViewerServer(opts: ServerOpts) {
return send(res, 200, JSON.stringify({ token, path: `/s/${token}/` }), "application/json");
}

// Save the named board as a reusable, global template (tokenless + wsid-gated, same rationale as
// share/clear — the viewer UI holds no push token; the unguessable wsid is the gate). Reads the
// board's stored content/type and mints a template id reusable from any future session.
// Reusable template library — list + fetch + delete, scoped under /w/<wsid>/ so the unguessable
// workspace id is the capability (consistent with clear/share). The library is GLOBAL (any of your
// workspaces sees every template, enabling cross-session reuse by id), but the read-only demo is
// excluded so the public showcase never enumerates or mutates it.
if (action === "templates" && req.method === "GET") {
if (isReadOnlyWsid(wsid)) return send(res, 404, "not found");
await hydratedForRead(TEMPLATES_WSID);
return send(res, 200, JSON.stringify(templateStore.list()), "application/json");
}
if (action === "template" && parts[3]) { // /w/<wsid>/template/<id>
if (isReadOnlyWsid(wsid)) return send(res, 404, "not found");
const id = parts[3];
if (req.method === "GET") {
await hydratedForRead(TEMPLATES_WSID);
const t = templateStore.get(id);
return t ? send(res, 200, JSON.stringify(t), "application/json") : send(res, 404, "no such template");
}
if (req.method === "DELETE") {
if (!(await hydratedForWrite(TEMPLATES_WSID))) return send(res, 503, "state backend unavailable; refusing to modify to avoid data loss");
const ok = templateStore.remove(id);
if (ok) await persistSave(TEMPLATES_WSID);
return ok ? send(res, 204) : send(res, 404, "no such template");
}
return send(res, 405, "method not allowed");
}

// Save the named board as a reusable, global template — wsid-gated (the unguessable workspace id is
// the capability, same as clear/share; the viewer UI holds no push token) and demo-blocked by the
// read-only POST guard above. Reads the board's stored content/type and mints a reusable id.
if (req.method === "POST" && action === "template") {
const body = await readJson(req).catch(() => null);
const b = (typeof body === "object" && body ? body : {}) as Record<string, unknown>;
Expand Down
54 changes: 54 additions & 0 deletions packages/viewer/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -911,3 +911,57 @@ describe("read-only demo workspace (seeded showcase)", () => {
expect(ok.status).toBe(204);
});
});

describe("template library", () => {
const saveTpl = (body: object) =>
fetch(`${base}/w/ws1/template`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });

const seedBoard = () => push({ project: "p", agent: "a", type: "markdown", content: "# hi", description: "d" });

it("saves a board as a template, lists it, fetches it, and deletes it (wsid-scoped)", async () => {
await seedBoard();
const created = await saveTpl({ project: "p", agent: "a", name: "My tpl", hints: "WHAT: x" });
expect(created.status).toBe(200);
const { id } = await created.json();
expect(id).toMatch(/^tpl_/);

const list = await fetch(`${base}/w/ws1/templates`).then((r) => r.json());
expect(list).toHaveLength(1);
expect(list[0]).toMatchObject({ id, name: "My tpl", type: "markdown" });
expect(list[0]).not.toHaveProperty("content"); // summary only

const got = await fetch(`${base}/w/ws1/template/${id}`).then((r) => r.json());
expect(got).toMatchObject({ id, content: "# hi", hints: "WHAT: x", type: "markdown" });

expect((await fetch(`${base}/w/ws1/template/${id}`, { method: "DELETE" })).status).toBe(204);
expect(await fetch(`${base}/w/ws1/templates`).then((r) => r.json())).toHaveLength(0);
});

it("rejects saving a nonexistent board (404) and missing fields (400)", async () => {
expect((await saveTpl({ project: "nope", agent: "nope", name: "x" })).status).toBe(404);
expect((await saveTpl({})).status).toBe(400);
});

it("404s an unknown id and 405s a wrong method on /template/<id>", async () => {
expect((await fetch(`${base}/w/ws1/template/tpl_missing`)).status).toBe(404);
expect((await fetch(`${base}/w/ws1/template/tpl_missing`, { method: "POST" })).status).toBe(405);
});

it("is wsid-gated, not origin-rooted (the open routes are gone)", async () => {
await seedBoard();
const { id } = await (await saveTpl({ project: "p", agent: "a", name: "n", hints: "h" })).json();
// No anonymous, workspace-less catalog or delete.
expect((await fetch(`${base}/templates`)).status).toBe(404);
expect((await fetch(`${base}/template/${id}`, { method: "DELETE" })).status).not.toBe(204);
});

it("excludes the read-only demo (no save, no enumeration)", async () => {
expect((await fetch(`${base}/w/demo/template`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status).toBe(403);
expect((await fetch(`${base}/w/demo/templates`)).status).toBe(404);
});

it("never serves the reserved __templates__ store wsid", async () => {
expect((await fetch(`${base}/w/__templates__/state`)).status).toBe(404);
expect((await fetch(`${base}/w/__templates__/templates`)).status).toBe(404);
});
});
Loading