@1agh/maude 0.52.2 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -291,6 +291,55 @@ export function sameOriginRead(req: Request): boolean {
291
291
  return site === 'same-origin' || site === 'none';
292
292
  }
293
293
 
294
+ // ─────────────────────────────────────────────────────────────────────────────
295
+ // Cloud Phase 25 C2 — the local half of the read-only gate.
296
+ //
297
+ // When the project is linked to a hub that vouched a `viewer` role at sign-in
298
+ // (isHubReadOnly), the LOCAL dev-server refuses project-mutating writes too.
299
+ // The cell is the authority (Phase 25 C1 — it refuses whatever a patched
300
+ // client sends); this gate exists so a viewer's local clone never DIVERGES
301
+ // from the hub: a local write the cell would refuse to sync is a silent fork,
302
+ // which is worse than a refusal. Mirrors C1's posture — writes are
303
+ // default-denied with a short, explicit allowlist.
304
+ //
305
+ // What a read-only session may still write (per-user runtime state per
306
+ // DDR-115, plus the two role-granted verbs the cell itself allows —
307
+ // export ≈ `/api/export`, session management ≈ `/auth/logout`):
308
+ const READ_ONLY_SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
309
+ export const READ_ONLY_ALLOWED_WRITES = new Set([
310
+ '/_hmr', // build-watch rebuild hint — dev plumbing, not project state
311
+ '/_canvas-state', // per-user camera / view state (DDR-115: never versioned)
312
+ '/_api/canvas-meta', // viewport lane only — the layout lane is refused in-handler
313
+ '/_api/ui-prefs', // per-user UI preferences
314
+ '/_api/timeline-media', // per-user runtime media cache (scrub read path)
315
+ '/_api/export', // "look, comment and download" — the cell allows /api/export too
316
+ '/_api/export-jobs',
317
+ '/_api/export-jobs/download',
318
+ '/_api/report', // bug reports are about Maude, not the project
319
+ '/_api/report-fallback',
320
+ '/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
321
+ '/_api/workspace/sign-in', // signing in is how the role is (re)learned
322
+ '/_api/workspace/disclosure',
323
+ '/_api/cloud/signin/start', // account session, not project state
324
+ '/_api/cloud/signin/poll',
325
+ '/_api/cloud/signout',
326
+ '/_api/git/fetch', // syncing the project DOWN is a read of the project
327
+ '/_api/git/pull',
328
+ '/_api/git/checkout', // viewing another branch, not changing one
329
+ ]);
330
+
331
+ /** The refusal a read-only session gets for a project-mutating write. */
332
+ export function readOnlyRefusalResponse(): Response {
333
+ return Response.json(
334
+ {
335
+ error: 'read-only',
336
+ reason: 'read-only',
337
+ message: 'Your role in this project is viewer — editing is disabled.',
338
+ },
339
+ { status: 403, headers: { 'Cache-Control': 'no-store' } }
340
+ );
341
+ }
342
+
294
343
  function safePathUnderRoot(reqUrl: string, repoRoot: string): string | null {
295
344
  let pathname: string;
296
345
  try {
@@ -1173,7 +1222,7 @@ export function createHttp(
1173
1222
  Response.json({
1174
1223
  ...ctx.cfg,
1175
1224
  canvasOrigin: ctx.canvasOrigin,
1176
- readOnly: ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false,
1225
+ readOnly: projectReadOnly(),
1177
1226
  }),
1178
1227
 
1179
1228
  // What's New feed (DDR-A) — read-only product-update list surfaced in the
@@ -1214,7 +1263,13 @@ export function createHttp(
1214
1263
  return new Response('local request required (DNS-rebinding guard)', { status: 403 });
1215
1264
  const endpoint = process.env.MAUDE_REPORT_URL || 'https://cloud.maude.sh/report';
1216
1265
  try {
1217
- const upstream = await fetch(endpoint, {
1266
+ // `globalThis.fetch` — NOT bare `fetch`: this module declares its own
1267
+ // `async function fetch(req: Request)` (the server fall-through handler)
1268
+ // in the same scope, which shadows the global. A bare call re-entered the
1269
+ // fall-through with a URL string, so `new URL(req.url)` threw
1270
+ // `"undefined" cannot be parsed as a URL` and every report died with a
1271
+ // plain-text HTTP 500 without ever leaving the machine.
1272
+ const upstream = await globalThis.fetch(endpoint, {
1218
1273
  method: 'POST',
1219
1274
  body: await req.formData(),
1220
1275
  signal: AbortSignal.timeout(30_000),
@@ -1311,6 +1366,14 @@ export function createHttp(
1311
1366
  if (!body.patch || typeof body.patch !== 'object') {
1312
1367
  return new Response('body.patch must be an object', { status: 400 });
1313
1368
  }
1369
+ // Cloud Phase 25 C2 — this route is on READ_ONLY_ALLOWED_WRITES for its
1370
+ // VIEWPORT lane only (per-user camera, split into `_canvas-state/` by
1371
+ // the api layer — DDR-115). The layout lane mutates the versioned
1372
+ // `.meta.json`, so a read-only session is refused here, in-handler,
1373
+ // where the two lanes are distinguishable.
1374
+ if (projectReadOnly() && 'layout' in body.patch) {
1375
+ return readOnlyRefusalResponse();
1376
+ }
1314
1377
  const next = await api.patchCanvasMeta(body.file, body.patch);
1315
1378
  if (!next) return new Response('Not found or rejected', { status: 404 });
1316
1379
  return Response.json(next, { headers: { 'Cache-Control': 'no-store' } });
@@ -3891,7 +3954,11 @@ export function createHttp(
3891
3954
  '/index.html': () => serveFile(join(CLIENT_DIR, 'index.html')),
3892
3955
  } satisfies Record<string, (req: Request) => Response | Promise<Response>>;
3893
3956
 
3894
- async function fetch(req: Request): Promise<Response> {
3957
+ // Named `handleFallthrough`, not `fetch` — a same-named local function shadows
3958
+ // the global `fetch` for every call site in this module's scope, which is how
3959
+ // the `/_api/report` route ended up calling itself instead of the network (see
3960
+ // the `globalThis.fetch` comment on that route; RCA issue-report-a-bug-http-500).
3961
+ async function handleFallthrough(req: Request): Promise<Response> {
3895
3962
  try {
3896
3963
  const url = new URL(req.url);
3897
3964
  const pathname = url.pathname;
@@ -4254,5 +4321,39 @@ export function createHttp(
4254
4321
  return false;
4255
4322
  }
4256
4323
 
4257
- return { routes, fetch, serveCanvasShell, isCanvasSafeRoute };
4324
+ // Cloud Phase 25 C2 — read-only gate over every write surface (~42 mutating
4325
+ // routes). Computed per-request so a role change lands on the next write
4326
+ // (workspace sign-in rewrites hubs.json; no restart needed). Wrapping here
4327
+ // covers all three doors at once: the main-origin `routes` table, the
4328
+ // canvas-origin `routes` allowlist in server.ts (it references these same
4329
+ // handlers), and the dynamic-path `fetch` fall-through (comment replies).
4330
+ function projectReadOnly(): boolean {
4331
+ return ctx.cfg.linkedHub ? isHubReadOnly(ctx.cfg.linkedHub.url) : false;
4332
+ }
4333
+
4334
+ function readOnlyRefusal(req: Request): Response | null {
4335
+ if (READ_ONLY_SAFE_METHODS.has(req.method)) return null;
4336
+ if (!projectReadOnly()) return null;
4337
+ let pathname: string;
4338
+ try {
4339
+ pathname = new URL(req.url).pathname;
4340
+ } catch {
4341
+ return readOnlyRefusalResponse();
4342
+ }
4343
+ if (READ_ONLY_ALLOWED_WRITES.has(pathname)) return null;
4344
+ return readOnlyRefusalResponse();
4345
+ }
4346
+
4347
+ const guardedRoutes = Object.fromEntries(
4348
+ Object.entries(routes).map(([path, handler]) => [
4349
+ path,
4350
+ (req: Request) => readOnlyRefusal(req) ?? handler(req),
4351
+ ])
4352
+ ) as typeof routes;
4353
+
4354
+ async function guardedFetch(req: Request): Promise<Response> {
4355
+ return readOnlyRefusal(req) ?? handleFallthrough(req);
4356
+ }
4357
+
4358
+ return { routes: guardedRoutes, fetch: guardedFetch, serveCanvasShell, isCanvasSafeRoute };
4258
4359
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @file read-only-mode.ts — Cloud Phase 25 C2
3
+ * @scope apps/studio/read-only-mode.ts
4
+ * @purpose One boot-static answer to "is this canvas read-only?" for every
5
+ * module that runs inside the canvas iframe.
6
+ *
7
+ * The shell appends `?ro=1` to the canvas URL (client/canvas-url.js) when
8
+ * `/_config.readOnly` is true — i.e. the linked hub vouched a `viewer` role at
9
+ * workspace sign-in (sync/hubs-config.ts). Boot-static on purpose: a role is
10
+ * per-session, and reading the URL can't race the way a post-load postMessage
11
+ * could (during which a viewer could still drag something).
12
+ *
13
+ * This flag decides what the canvas chrome OFFERS — tools, drag handles,
14
+ * inline edit, context menus. It never STOPS a write; that is the cell
15
+ * (Phase 25 C1) and the dev-server's read-only gate (http.ts), both of which
16
+ * hold whatever this returns.
17
+ */
18
+
19
+ let cached: boolean | null = null;
20
+
21
+ export function isReadOnlyCanvas(): boolean {
22
+ if (typeof window === 'undefined') return false;
23
+ if (cached === null) {
24
+ try {
25
+ cached = new URLSearchParams(window.location.search).get('ro') === '1';
26
+ } catch {
27
+ cached = false;
28
+ }
29
+ }
30
+ return cached;
31
+ }
32
+
33
+ /** Test seam — reset the module cache (bun:test re-uses the module registry). */
34
+ export function _resetReadOnlyCache(): void {
35
+ cached = null;
36
+ }
@@ -96,4 +96,17 @@ describe('canvasUrl — per-canvas design-system token resolution', () => {
96
96
  test('non-tsx paths bypass the shell and return a direct URL', () => {
97
97
  expect(canvasUrl('.design/ui/legacy.html', cfg)).toBe(urlOf('.design/ui/legacy.html'));
98
98
  });
99
+
100
+ // Cloud Phase 25 C2 — the viewer role rides into the canvas as ?ro=1 so the
101
+ // iframe chrome is read-only from its very first paint (boot-static, no
102
+ // postMessage race).
103
+ test('cfg.readOnly stamps ro=1; absent/false omits it', () => {
104
+ expect(paramsOf(canvasUrl('.design/ui/Studio.tsx', { ...cfg, readOnly: true })).get('ro')).toBe(
105
+ '1'
106
+ );
107
+ expect(paramsOf(canvasUrl('.design/ui/Studio.tsx', cfg)).get('ro')).toBeNull();
108
+ expect(
109
+ paramsOf(canvasUrl('.design/ui/Studio.tsx', { ...cfg, readOnly: false })).get('ro')
110
+ ).toBeNull();
111
+ });
99
112
  });
@@ -0,0 +1,130 @@
1
+ // Cloud Phase 25 C2 — the local half of the read-only gate.
2
+ //
3
+ // When the linked hub's credential carries the `viewer` role (vouched at
4
+ // workspace sign-in, stored in hubs.json), the LOCAL dev-server refuses
5
+ // project-mutating writes with a legible 403 — so a viewer's clone can never
6
+ // silently diverge from the cell (which refuses the same writes at sync,
7
+ // Phase 25 C1). Writes are default-denied with a short allowlist
8
+ // (READ_ONLY_ALLOWED_WRITES in http.ts): per-user runtime state (camera,
9
+ // ui-prefs) plus the two role-granted verbs the cell itself allows (export,
10
+ // session management).
11
+
12
+ import { afterAll, describe, expect, test } from 'bun:test';
13
+ import { readFileSync, writeFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import type { Subprocess } from 'bun';
16
+
17
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
18
+
19
+ const HUB_URL = 'http://hub.invalid:9';
20
+
21
+ /** Sandbox linked to a hub whose credential vouches `role`. */
22
+ function makeLinkedSandbox(role?: string) {
23
+ const { root, designRoot } = makeSandbox();
24
+ const cfgPath = join(designRoot, 'config.json');
25
+ const cfg = JSON.parse(readFileSync(cfgPath, 'utf8'));
26
+ cfg.linkedHub = { url: HUB_URL, linkedAt: Date.now() };
27
+ writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
28
+ // The hubs.json credential store the role is read from (isHubReadOnly).
29
+ const hubsPath = join(root, 'hubs.json');
30
+ writeFileSync(
31
+ hubsPath,
32
+ JSON.stringify({
33
+ hubs: { [HUB_URL]: { token: 't', linkedAt: Date.now(), ...(role ? { role } : {}) } },
34
+ })
35
+ );
36
+ return { root, designRoot, hubsPath };
37
+ }
38
+
39
+ const procs: Subprocess[] = [];
40
+ afterAll(async () => {
41
+ for (const p of procs) await killProc(p);
42
+ });
43
+
44
+ async function boot(role?: string) {
45
+ const { root, hubsPath } = makeLinkedSandbox(role);
46
+ const port = nextPort();
47
+ const proc = await bootServer(root, port, { HUBS_CONFIG_PATH: hubsPath });
48
+ procs.push(proc);
49
+ return { base: `http://localhost:${port}` };
50
+ }
51
+
52
+ describe('read-only gate — viewer role refuses project writes locally', () => {
53
+ test('viewer: writes 403 read-only, per-user + role-granted writes pass, /_config says so', async () => {
54
+ const { base } = await boot('viewer');
55
+
56
+ // /_config tells the client at boot.
57
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
58
+ expect(cfg.readOnly).toBe(true);
59
+
60
+ // Project-mutating writes: refused with the legible read-only shape.
61
+ const editText = await fetch(`${base}/_api/edit-text`, {
62
+ method: 'POST',
63
+ headers: { 'content-type': 'application/json' },
64
+ body: JSON.stringify({ file: 'ui/fixture.html', text: 'x' }),
65
+ });
66
+ expect(editText.status).toBe(403);
67
+ const body = (await editText.json()) as { error?: string; message?: string };
68
+ expect(body.error).toBe('read-only');
69
+ expect(body.message).toContain('viewer');
70
+
71
+ // Canvas create + delete-element + git commit: same refusal.
72
+ for (const [path, method] of [
73
+ ['/_api/canvas', 'POST'],
74
+ ['/_api/delete-element', 'POST'],
75
+ ['/_api/git/commit', 'POST'],
76
+ ['/_api/fs-mkdir', 'POST'],
77
+ ['/_api/annotations', 'PUT'],
78
+ ['/_api/acp/chat', 'POST'],
79
+ ] as const) {
80
+ const r = await fetch(base + path, { method, body: '{}' });
81
+ expect(`${path} ${r.status}`).toBe(`${path} 403`);
82
+ }
83
+
84
+ // canvas-meta: the LAYOUT lane is refused…
85
+ const layout = await fetch(`${base}/_api/canvas-meta`, {
86
+ method: 'PATCH',
87
+ headers: { 'content-type': 'application/json' },
88
+ body: JSON.stringify({
89
+ file: '.design/ui/fixture.html',
90
+ patch: { layout: { artboards: [] } },
91
+ }),
92
+ });
93
+ expect(layout.status).toBe(403);
94
+ // …the VIEWPORT lane (per-user camera) is not.
95
+ const viewport = await fetch(`${base}/_api/canvas-meta`, {
96
+ method: 'PATCH',
97
+ headers: { 'content-type': 'application/json' },
98
+ body: JSON.stringify({
99
+ file: '.design/ui/fixture.html',
100
+ patch: { viewport: { x: 0, y: 0, zoom: 1 } },
101
+ }),
102
+ });
103
+ expect(viewport.status).not.toBe(403);
104
+
105
+ // Per-user runtime state stays writable (DDR-115 taxonomy).
106
+ const camera = await fetch(`${base}/_canvas-state`, {
107
+ method: 'POST',
108
+ headers: { 'content-type': 'application/json' },
109
+ body: JSON.stringify({ file: '.design/ui/fixture.html', viewport: { x: 1, y: 2, zoom: 1 } }),
110
+ });
111
+ expect(camera.status).toBe(204);
112
+
113
+ // Role-granted verb: export reaches its handler (may 400 on shape, never 403).
114
+ const exp = await fetch(`${base}/_api/export`, { method: 'POST', body: '{}' });
115
+ expect(exp.status).not.toBe(403);
116
+
117
+ // Reads are untouched.
118
+ expect((await fetch(`${base}/_index-data`)).status).toBe(200);
119
+ expect((await fetch(`${base}/_comments-all`)).status).toBe(200);
120
+ }, 30000);
121
+
122
+ test('member (no role on the credential): writes are NOT gated', async () => {
123
+ const { base } = await boot(undefined);
124
+ const cfg = (await (await fetch(`${base}/_config`)).json()) as { readOnly?: boolean };
125
+ expect(cfg.readOnly).toBe(false);
126
+ // A write reaches its handler (400 on bad shape — not the 403 refusal).
127
+ const r = await fetch(`${base}/_api/edit-text`, { method: 'POST', body: '{}' });
128
+ expect(r.status).not.toBe(403);
129
+ }, 30000);
130
+ });
@@ -0,0 +1,125 @@
1
+ // Regression test for issue-report-a-bug-http-500 (RCA:
2
+ // .ai/logs/rca/issue-report-a-bug-http-500.md). `/_api/report` proxies the
3
+ // dialog's multipart body to `MAUDE_REPORT_URL`. A bare `fetch(endpoint, …)`
4
+ // call inside http.ts silently resolved to this module's OWN same-named
5
+ // fall-through handler instead of the global `fetch` — the request never left
6
+ // the machine, and the route mirrored a bogus local 500 back to the client.
7
+ // These tests boot a real server pointed at a real stub upstream, so a
8
+ // reintroduced shadow (or any other break in the proxy hop) fails loudly
9
+ // instead of only being caught by a mocked-upstream unit test.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+ import { readFileSync } from 'node:fs';
13
+
14
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
15
+
16
+ async function bootStubUpstream(
17
+ handler: (req: Request) => Response | Promise<Response>
18
+ ): Promise<{ url: string; stop: () => void; received: () => number }> {
19
+ let received = 0;
20
+ const server = Bun.serve({
21
+ port: 0,
22
+ fetch: async (req) => {
23
+ received += 1;
24
+ return handler(req);
25
+ },
26
+ });
27
+ return {
28
+ url: `http://127.0.0.1:${server.port}`,
29
+ stop: () => server.stop(true),
30
+ received: () => received,
31
+ };
32
+ }
33
+
34
+ describe('/_api/report proxy hop', () => {
35
+ test('forwards the multipart body verbatim and mirrors a successful upstream response', async () => {
36
+ const { root } = makeSandbox();
37
+ let capturedBytes = 0;
38
+ let capturedHasReportPart = false;
39
+ const upstream = await bootStubUpstream(async (req) => {
40
+ const form = await req.formData();
41
+ capturedHasReportPart = typeof form.get('report') === 'string';
42
+ // Multipart parsing consumed the body — approximate "did real bytes
43
+ // arrive" by re-serializing what FormData kept.
44
+ capturedBytes = [...form.values()].reduce(
45
+ (n, v) => n + (typeof v === 'string' ? v.length : (v as Blob).size),
46
+ 0
47
+ );
48
+ return Response.json({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
49
+ });
50
+
51
+ const port = nextPort();
52
+ const proc = await bootServer(root, port, { MAUDE_REPORT_URL: `${upstream.url}/report` });
53
+ try {
54
+ const form = new FormData();
55
+ form.set(
56
+ 'report',
57
+ JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
58
+ );
59
+ form.append('screenshot', new Blob([new Uint8Array([1, 2, 3, 4])]), 'shot.png');
60
+
61
+ const res = await fetch(`http://localhost:${port}/_api/report`, {
62
+ method: 'POST',
63
+ body: form,
64
+ });
65
+ expect(res.status).toBe(200);
66
+ const json = await res.json();
67
+ expect(json).toEqual({ ok: true, issueNumber: 1, issueUrl: 'https://example.com/issues/1' });
68
+
69
+ expect(upstream.received()).toBe(1);
70
+ expect(capturedHasReportPart).toBe(true);
71
+ expect(capturedBytes).toBeGreaterThan(0);
72
+ } finally {
73
+ await killProc(proc);
74
+ upstream.stop();
75
+ }
76
+ });
77
+
78
+ test('an unreachable upstream yields 502 with a JSON error, not a bare 500', async () => {
79
+ const { root } = makeSandbox();
80
+ const port = nextPort();
81
+ // Nothing listens on this port — the fetch itself must fail/timeout.
82
+ const proc = await bootServer(root, port, { MAUDE_REPORT_URL: 'http://127.0.0.1:1/report' });
83
+ try {
84
+ const form = new FormData();
85
+ form.set(
86
+ 'report',
87
+ JSON.stringify({ schema: 'maude-report/v1', description: 'test', reportId: 'r-0123abcd' })
88
+ );
89
+ const res = await fetch(`http://localhost:${port}/_api/report`, {
90
+ method: 'POST',
91
+ body: form,
92
+ });
93
+ expect(res.status).toBe(502);
94
+ const json = await res.json();
95
+ expect(json).toEqual({ error: 'report service unreachable' });
96
+ } finally {
97
+ await killProc(proc);
98
+ }
99
+ });
100
+ });
101
+
102
+ describe('no bare `fetch(` call sites in http.ts', () => {
103
+ test('every fetch call is either the global fetch (qualified) or a local handler name', () => {
104
+ const src = readFileSync(new URL('../http.ts', import.meta.url), 'utf8');
105
+ // A bare `fetch(` immediately preceded by a word-boundary and NOT preceded
106
+ // by a `.` (member access) or `function ` (declaration) would silently
107
+ // resolve to this file's own same-named fall-through handler instead of
108
+ // the global — exactly the bug this test guards against. `handleFallthrough`
109
+ // is the current, unambiguous name; this assertion also fails if the
110
+ // handler is ever renamed back to something call-site-ambiguous.
111
+ const bareFetchCalls = [...src.matchAll(/(?<![.\w])fetch\(/g)];
112
+ const lines = src.split('\n');
113
+ // Skip: comment lines (mentioning `fetch(` in prose is fine) and the one
114
+ // legitimate bare occurrence — the type-signature parameter name
115
+ // `fetch(req: Request): Promise<Response>;` in the `Api`-shaped interface,
116
+ // which is a declaration, not a call.
117
+ const offendingLines = bareFetchCalls
118
+ .map((m) => src.slice(0, m.index).split('\n').length)
119
+ .map((line) => ({ line, text: lines[line - 1] ?? '' }))
120
+ .filter(({ text }) => !/^\s*(\/\/|\*)/.test(text))
121
+ .filter(({ text }) => !/^\s*fetch\(req: Request\): Promise<Response>;/.test(text));
122
+
123
+ expect(offendingLines).toEqual([]);
124
+ });
125
+ });
@@ -6,6 +6,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
6
6
 
7
7
  import {
8
8
  DEFAULT_TOOLS,
9
+ filterToolsForReadOnly,
9
10
  ToolProvider,
10
11
  useToolMode,
11
12
  useToolModeOptional,
@@ -131,3 +132,25 @@ describe('use-tool-mode / SSR render', () => {
131
132
  );
132
133
  });
133
134
  });
135
+
136
+ // Cloud Phase 25 C2 — read-only tool filtering. The provider applies this to
137
+ // its `tools` when the canvas booted with ?ro=1; the pure function is what the
138
+ // contract pins: navigate/inspect only, every write tool absent.
139
+ describe('use-tool-mode / read-only filter', () => {
140
+ test('read-only keeps exactly browse + move + hand, in order', () => {
141
+ expect(filterToolsForReadOnly(DEFAULT_TOOLS, true).map((t) => t.id)).toEqual([
142
+ 'browse',
143
+ 'move',
144
+ 'hand',
145
+ ]);
146
+ });
147
+
148
+ test('comment is filtered too — the cell refuses viewer comments until C3', () => {
149
+ const ids = filterToolsForReadOnly(DEFAULT_TOOLS, true).map((t) => t.id);
150
+ expect(ids).not.toContain('comment');
151
+ });
152
+
153
+ test('readOnly=false is identity', () => {
154
+ expect(filterToolsForReadOnly(DEFAULT_TOOLS, false)).toEqual([...DEFAULT_TOOLS]);
155
+ });
156
+ });
@@ -22,6 +22,7 @@ import {
22
22
  SHAPE_KIND_ICONS,
23
23
  TOOL_ICONS,
24
24
  } from './canvas-icons.tsx';
25
+ import { isReadOnlyCanvas } from './read-only-mode.ts';
25
26
  import { useChromeVisibility } from './use-chrome-visibility.tsx';
26
27
  import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
27
28
 
@@ -395,6 +396,11 @@ export function ToolPalette() {
395
396
  const byId = new Map(tools.map((t) => [t.id, t]));
396
397
  const navList = NAV_TOOLS.map((id) => byId.get(id)).filter(Boolean);
397
398
  const drawList = DRAW_TOOLS.map((id) => byId.get(id)).filter(Boolean);
399
+ // Cloud Phase 25 C2 — read-only canvas: the draw group is already empty
400
+ // (ToolProvider filters `tools`), and the hardcoded write affordances
401
+ // (Insert +, Stickers) must be ABSENT too. Export + Presentation stay —
402
+ // both are reads of the project.
403
+ const readOnly = isReadOnlyCanvas();
398
404
 
399
405
  const renderToolButton = (id: string, label: string, shortcut: string) => {
400
406
  const Icon = TOOL_ICONS[id];
@@ -483,66 +489,74 @@ export function ToolPalette() {
483
489
  <div className="dc-tp-group">
484
490
  {navList.map((t) => (t ? renderToolButton(t.id, t.label, t.shortcut) : null))}
485
491
  </div>
492
+ {drawList.length > 0 ? (
493
+ <>
494
+ <div className="dc-tp-sep" />
495
+ <div className="dc-tp-group">
496
+ {drawList.map((t) =>
497
+ t
498
+ ? t.id === 'shape'
499
+ ? renderShapeButton()
500
+ : renderToolButton(t.id, t.label, t.shortcut)
501
+ : null
502
+ )}
503
+ </div>
504
+ </>
505
+ ) : null}
486
506
  <div className="dc-tp-sep" />
487
507
  <div className="dc-tp-group">
488
- {drawList.map((t) =>
489
- t
490
- ? t.id === 'shape'
491
- ? renderShapeButton()
492
- : renderToolButton(t.id, t.label, t.shortcut)
493
- : null
508
+ {!readOnly && (
509
+ <span className="dc-tp-insert">
510
+ <button
511
+ type="button"
512
+ aria-label="Insert element — Div, Text, or Image"
513
+ aria-haspopup="menu"
514
+ aria-expanded={insertOpen}
515
+ title="Insert element (appends to the active artboard)"
516
+ onClick={() => setInsertOpen((o) => !o)}
517
+ >
518
+ <span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
519
+ +
520
+ </span>
521
+ </button>
522
+ {insertOpen ? (
523
+ <div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
524
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
525
+ <IconSquare />
526
+ <span className="dc-tp-insert-label">Div</span>
527
+ </button>
528
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
529
+ <IconLetterA />
530
+ <span className="dc-tp-insert-label">Text</span>
531
+ </button>
532
+ <button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
533
+ <span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
534
+ ▧
535
+ </span>
536
+ <span className="dc-tp-insert-label">Image</span>
537
+ </button>
538
+ </div>
539
+ ) : null}
540
+ </span>
494
541
  )}
495
- </div>
496
- <div className="dc-tp-sep" />
497
- <div className="dc-tp-group">
498
- <span className="dc-tp-insert">
542
+ {!readOnly && (
499
543
  <button
500
544
  type="button"
501
- aria-label="Insert element — Div, Text, or Image"
502
- aria-haspopup="menu"
503
- aria-expanded={insertOpen}
504
- title="Insert element (appends to the active artboard)"
505
- onClick={() => setInsertOpen((o) => !o)}
545
+ aria-label="Stickers"
546
+ title="Stickers — searchable sticker picker"
547
+ onClick={() => {
548
+ try {
549
+ window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
550
+ } catch {
551
+ /* detached / cross-origin teardown */
552
+ }
553
+ }}
506
554
  >
507
555
  <span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
508
- +
556
+ 🙂
509
557
  </span>
510
558
  </button>
511
- {insertOpen ? (
512
- <div className="dc-tp-insert-popover" role="menu" aria-label="Insert element">
513
- <button type="button" role="menuitem" onClick={() => insertViaPalette('div')}>
514
- <IconSquare />
515
- <span className="dc-tp-insert-label">Div</span>
516
- </button>
517
- <button type="button" role="menuitem" onClick={() => insertViaPalette('text')}>
518
- <IconLetterA />
519
- <span className="dc-tp-insert-label">Text</span>
520
- </button>
521
- <button type="button" role="menuitem" onClick={() => insertViaPalette('image')}>
522
- <span aria-hidden="true" style={{ fontSize: 13, lineHeight: 1 }}>
523
- ▧
524
- </span>
525
- <span className="dc-tp-insert-label">Image</span>
526
- </button>
527
- </div>
528
- ) : null}
529
- </span>
530
- <button
531
- type="button"
532
- aria-label="Stickers"
533
- title="Stickers — searchable sticker picker"
534
- onClick={() => {
535
- try {
536
- window.parent.postMessage({ dgn: 'open-sticker-picker' }, '*');
537
- } catch {
538
- /* detached / cross-origin teardown */
539
- }
540
- }}
541
- >
542
- <span aria-hidden="true" style={{ fontSize: 16, lineHeight: 1 }}>
543
- 🙂
544
- </span>
545
- </button>
559
+ )}
546
560
  <button
547
561
  type="button"
548
562
  aria-label="Export (⌘E)"