diff --git a/packages/cli/src/template.ts b/packages/cli/src/template.ts index b18b466..52a2775 100644 --- a/packages/cli/src/template.ts +++ b/packages/cli/src/template.ts @@ -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 `, 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/`), so they're -// derived from the URL's origin rather than the `/w/` base. +// All subcommands hit the wsid-scoped `${base}` (`…/w/`): `save` posts to `${base}/template` +// (reads that board's content), and `list`/`get`/`delete` use `${base}/templates` and +// `${base}/template/`. 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; @@ -44,15 +45,13 @@ export async function template(argv: string[], deps: TemplateDeps): Promise = 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`); @@ -111,10 +110,10 @@ function parseRead(argv: string[]): ReadArgs { return a; } -async function listTemplates(argv: string[], origin: string, headers: Record): Promise { +async function listTemplates(argv: string[], base: string, headers: Record): Promise { 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; } @@ -124,11 +123,11 @@ async function listTemplates(argv: string[], origin: string, headers: Record): Promise { +async function getTemplate(argv: string[], base: string, headers: Record): Promise { 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; @@ -138,11 +137,11 @@ async function getTemplate(argv: string[], origin: string, headers: Record): Promise { +async function deleteTemplate(argv: string[], base: string, headers: Record): Promise { 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`); diff --git a/packages/cli/test/template.test.ts b/packages/cli/test/template.test.ts index a7578ec..34675b7 100644 --- a/packages/cli/test/template.test.ts +++ b/packages/cli/test/template.test.ts @@ -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"); @@ -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 }); @@ -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"); }); diff --git a/packages/viewer/e2e/template.e2e.mjs b/packages/viewer/e2e/template.e2e.mjs index c0d7a77..06cc740 100644 --- a/packages/viewer/e2e/template.e2e.mjs +++ b/packages/viewer/e2e/template.e2e.mjs @@ -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/ returns hints + example content", full.hints.includes("WHAT:") && full.content.length > 10 && full.type === "datatable"); - ok("GET /template/ is 404", (await fetch(`${BASE}/template/tpl_missing`)).status === 404); + ok("GET /template/ 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); @@ -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/ 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/ 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/ 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) { diff --git a/packages/viewer/src/client/viewer.ts b/packages/viewer/src/client/viewer.ts index 1c6d606..0fbecae 100644 --- a/packages/viewer/src/client/viewer.ts +++ b/packages/viewer/src/client/viewer.ts @@ -842,7 +842,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise { 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) { @@ -875,7 +875,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise { 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); @@ -888,7 +888,7 @@ async function renderTemplateList(wrap: HTMLElement): Promise { 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)"; } diff --git a/packages/viewer/src/server.ts b/packages/viewer/src/server.ts index ac3621b..2dd1c4e 100644 --- a/packages/viewer/src/server.ts +++ b/packages/viewer/src/server.ts @@ -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/. - // Creating a template lives under /w//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 @@ -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// 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//template/ + 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; diff --git a/packages/viewer/test/server.test.ts b/packages/viewer/test/server.test.ts index dc5e91d..52fcd93 100644 --- a/packages/viewer/test/server.test.ts +++ b/packages/viewer/test/server.test.ts @@ -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/", 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); + }); +});