@1agh/maude 0.53.1 → 0.54.0

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.
Files changed (58) hide show
  1. package/apps/studio/api.ts +13 -7
  2. package/apps/studio/bin/screenshot.sh +173 -17
  3. package/apps/studio/build.ts +37 -3
  4. package/apps/studio/canvas-build-sandbox.ts +385 -0
  5. package/apps/studio/canvas-build-worker.ts +85 -0
  6. package/apps/studio/client/app.jsx +308 -21
  7. package/apps/studio/client/canvas-url.js +7 -0
  8. package/apps/studio/client/export-center.jsx +10 -2
  9. package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
  10. package/apps/studio/client/report-bug.jsx +125 -30
  11. package/apps/studio/client/styles/4-components-maude.css +5 -0
  12. package/apps/studio/client/styles/4-components.css +75 -0
  13. package/apps/studio/cloud-build.ts +208 -0
  14. package/apps/studio/collab/room.ts +43 -7
  15. package/apps/studio/context.ts +12 -5
  16. package/apps/studio/dist/client.bundle.js +910 -910
  17. package/apps/studio/dist/comment-mount.js +2 -2
  18. package/apps/studio/dist/styles.css +1 -1
  19. package/apps/studio/git/repo-lock.ts +305 -0
  20. package/apps/studio/git/service.ts +171 -21
  21. package/apps/studio/http.ts +383 -31
  22. package/apps/studio/inspect.ts +66 -6
  23. package/apps/studio/paths.ts +8 -0
  24. package/apps/studio/server.ts +216 -53
  25. package/apps/studio/session-scope.ts +101 -0
  26. package/apps/studio/sync/autocommit.ts +84 -46
  27. package/apps/studio/sync/index.ts +20 -0
  28. package/apps/studio/test/canvas-build-sandbox.test.ts +69 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
  30. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  31. package/apps/studio/test/canvas-url.test.ts +26 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-session-role.test.ts +131 -0
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/config-projection.test.ts +104 -0
  37. package/apps/studio/test/git-system-engine.test.ts +135 -0
  38. package/apps/studio/test/read-only-gate.test.ts +58 -0
  39. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  40. package/apps/studio/test/repo-lock.test.ts +350 -0
  41. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  42. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  43. package/apps/studio/test/session-scope.test.ts +100 -0
  44. package/apps/studio/test/shell-importmap.test.ts +8 -1
  45. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  46. package/apps/studio/test/sync-hardening.test.ts +75 -0
  47. package/apps/studio/test/workspace-containment.test.ts +90 -11
  48. package/apps/studio/use-collab.tsx +21 -1
  49. package/apps/studio/whats-new.json +47 -2
  50. package/apps/studio/workspace-mode.ts +161 -28
  51. package/apps/studio/ws.ts +74 -8
  52. package/cli/commands/kg.mjs +49 -19
  53. package/cli/lib/gitignore-block.mjs +3 -0
  54. package/package.json +11 -9
  55. package/plugins/design/dependencies.json +1 -1
  56. package/plugins/design/templates/_shell.html +66 -32
  57. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  58. package/plugins/flow/dependencies.json +1 -1
@@ -0,0 +1,100 @@
1
+ // Cloud Phase 27 D3 — whose runtime state is this?
2
+ //
3
+ // `_active.json` and `_canvas-state/<slug>.view.json` are per-MACHINE
4
+ // singletons by design (DDR-115), which is right for a desktop and wrong for a
5
+ // cell: one studio process serves an owner and a viewer, so a colleague opening
6
+ // a canvas switched YOUR tab and their pan moved YOUR camera. Silently — it
7
+ // read as flakiness, not as two people sharing one file.
8
+ //
9
+ // The first test is the one that matters most: OUTSIDE a cell nothing changes.
10
+
11
+ import { describe, expect, test } from 'bun:test';
12
+
13
+ import {
14
+ currentSession,
15
+ normalizeSessionKey,
16
+ runInSession,
17
+ sessionDir,
18
+ sessionFile,
19
+ } from '../session-scope.ts';
20
+
21
+ describe('session scope', () => {
22
+ test('with no session every path is byte-for-byte what it was', () => {
23
+ expect(currentSession()).toBe('');
24
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
25
+ expect(sessionFile('/repo/.design/_active.json')).toBe('/repo/.design/_active.json');
26
+ });
27
+
28
+ test('a session gives each member their own subtree and their own active file', () => {
29
+ runInSession('a1b2c3d4e5f60718', () => {
30
+ expect(currentSession()).toBe('a1b2c3d4e5f60718');
31
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe(
32
+ '/repo/.design/_canvas-state/a1b2c3d4e5f60718'
33
+ );
34
+ // A SIBLING of `_active.json`, not a rename: the runtime-state taxonomy,
35
+ // the gitignore and `isMaudeRuntimeState` all match that stem by name,
36
+ // and a sibling keeps every one of them correct with no fourth list.
37
+ expect(sessionFile('/repo/.design/_active.json')).toBe(
38
+ '/repo/.design/_active.a1b2c3d4e5f60718.json'
39
+ );
40
+ });
41
+ });
42
+
43
+ test('the scope does not leak past the call', () => {
44
+ runInSession('deadbeef', () => expect(currentSession()).toBe('deadbeef'));
45
+ expect(currentSession()).toBe('');
46
+ });
47
+
48
+ test('two sessions do not see each other', () => {
49
+ runInSession('one', () => {
50
+ expect(currentSession()).toBe('one');
51
+ runInSession('two', () => expect(currentSession()).toBe('two'));
52
+ expect(currentSession()).toBe('one');
53
+ });
54
+ });
55
+
56
+ test('the scope survives an await — a request is not one tick', async () => {
57
+ await runInSession('async1', async () => {
58
+ await new Promise((r) => setTimeout(r, 5));
59
+ expect(currentSession()).toBe('async1');
60
+ await Promise.resolve();
61
+ expect(currentSession()).toBe('async1');
62
+ });
63
+ });
64
+
65
+ test('a key that is not proxy-shaped is refused, not sanitized', () => {
66
+ // The value becomes a path segment, so this is the traversal gate — and it
67
+ // REJECTS rather than strips, because a key we had to repair is not one
68
+ // whose intent we should be guessing. Falling back to the shared singleton
69
+ // is the same behaviour a desktop already has.
70
+ for (const bad of [
71
+ '../../etc/passwd',
72
+ 'a/b',
73
+ 'a\\b',
74
+ '.',
75
+ '..',
76
+ 'has space',
77
+ 'x'.repeat(65),
78
+ '',
79
+ null,
80
+ undefined,
81
+ ]) {
82
+ expect(normalizeSessionKey(bad as string)).toBe('');
83
+ }
84
+ for (const good of ['abc123', 'a1b2c3d4e5f60718', 'A_b-9']) {
85
+ expect(normalizeSessionKey(good)).toBe(good);
86
+ }
87
+ // …and a refused key means the shared path, never a traversed one.
88
+ runInSession('../../etc', () => {
89
+ expect(sessionDir('/repo/.design/_canvas-state')).toBe('/repo/.design/_canvas-state');
90
+ });
91
+ });
92
+
93
+ test('a file with no extension still gets a distinct per-session name', () => {
94
+ runInSession('k', () => {
95
+ expect(sessionFile('/repo/.design/_active')).toBe('/repo/.design/_active.k');
96
+ // A dot in a PARENT directory must not be mistaken for the extension.
97
+ expect(sessionFile('/repo/my.dir/_active')).toBe('/repo/my.dir/_active.k');
98
+ });
99
+ });
100
+ });
@@ -43,7 +43,14 @@ describe('_shell.html importmap agrees with RUNTIME_PACKAGES', () => {
43
43
 
44
44
  for (const pkg of RUNTIME_PACKAGES) {
45
45
  test(`"${pkg}" resolves to its /_canvas-runtime/ bundle`, () => {
46
- expect(imports[pkg]).toBe(`/_canvas-runtime/${slugFor(pkg)}.js`);
46
+ // RELATIVE (`./`), and that leading dot is load-bearing (Cloud Phase 27).
47
+ // An importmap value resolves against the DOCUMENT's base URL, so a
48
+ // root-absolute `/_canvas-runtime/…` is correct only where the shell is
49
+ // served from the origin root. On the fleet's shared canvas host the
50
+ // shell lives under `/<project>/`, and every bundle 404'd there — the
51
+ // grey-canvas failure the per-project origin was introduced to end.
52
+ // Relative is right in BOTH shapes, so it is the one form to hold.
53
+ expect(imports[pkg]).toBe(`./_canvas-runtime/${slugFor(pkg)}.js`);
47
54
  });
48
55
  }
49
56
  });
@@ -12,6 +12,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import path from 'node:path';
14
14
 
15
+ import { REPO_LOCK_FILE } from '../git/repo-lock.ts';
15
16
  import {
16
17
  commitMessage,
17
18
  createAutoCommit,
@@ -332,3 +333,78 @@ describe('mirror push — never rewrites someone else’s work', () => {
332
333
  function silent() {
333
334
  return { warn: () => {}, error: () => {}, log: () => {} };
334
335
  }
336
+
337
+ describe('a contended lock must not lose the queue or the process (Phase 27 D2)', () => {
338
+ test('a lock timeout re-queues the files instead of dropping them', async () => {
339
+ // `flush()` clears `touched` BEFORE it commits, and `withRepoLock` THROWS
340
+ // when it cannot acquire. Without a catch, a contended autosave dropped its
341
+ // file list silently — the bytes stay on disk, uncommitted, with nothing to
342
+ // retry them — and rejected a promise the debounce timer discards, which on
343
+ // Node >= 15 takes the process down with it. Post-D2 the trigger is
344
+ // ordinary: `pull` and `checkout` hold this lock across a NETWORK call.
345
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-lock-'));
346
+ try {
347
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
348
+ // A live holder that is not stale — every acquire attempt will time out.
349
+ writeFileSync(
350
+ path.join(dir, '.git', REPO_LOCK_FILE),
351
+ JSON.stringify({ pid: process.pid, holder: 'studio:pull', at: Date.now(), token: 'held' })
352
+ );
353
+
354
+ const ran: string[][] = [];
355
+ const auto = createAutoCommit({
356
+ repoRoot: dir,
357
+ debounceMs: 5,
358
+ log: { warn() {}, error() {}, log() {} },
359
+ run: async (args) => {
360
+ ran.push(args);
361
+ return { code: 0, stdout: '', stderr: '' };
362
+ },
363
+ });
364
+
365
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
366
+ const outcome = await auto.flush();
367
+
368
+ expect(outcome?.ok).toBe(false);
369
+ // Nothing reached git — the lock was never acquired.
370
+ expect(ran).toEqual([]);
371
+ // THE POINT: the file is still queued, so the next quiescence retries it.
372
+ expect(auto.pending()).toEqual(['.design/ui/Home.tsx']);
373
+ auto.stop();
374
+ } finally {
375
+ rmSync(dir, { recursive: true, force: true });
376
+ }
377
+ }, 30_000);
378
+
379
+ test('the debounce timer never surfaces an unhandled rejection', async () => {
380
+ // The timer fires unattended; a rejection it discards is a process exit.
381
+ const dir = mkdtempSync(path.join(tmpdir(), 'autocommit-timer-'));
382
+ try {
383
+ mkdirSync(path.join(dir, '.git'), { recursive: true });
384
+ writeFileSync(
385
+ path.join(dir, '.git', REPO_LOCK_FILE),
386
+ JSON.stringify({ pid: process.pid, holder: 'studio:fold', at: Date.now(), token: 'held' })
387
+ );
388
+ const rejections: unknown[] = [];
389
+ const onUnhandled = (err: unknown) => rejections.push(err);
390
+ process.on('unhandledRejection', onUnhandled);
391
+ try {
392
+ const auto = createAutoCommit({
393
+ repoRoot: dir,
394
+ debounceMs: 5,
395
+ log: { warn() {}, error() {}, log() {} },
396
+ run: async () => ({ code: 0, stdout: '', stderr: '' }),
397
+ });
398
+ auto.note('.design/ui/Home.tsx', { name: 'A', email: 'a@b.c' });
399
+ // Long enough for the debounce to fire AND the lock wait to expire.
400
+ await new Promise((r) => setTimeout(r, 1000));
401
+ auto.stop();
402
+ } finally {
403
+ process.off('unhandledRejection', onUnhandled);
404
+ }
405
+ expect(rejections).toEqual([]);
406
+ } finally {
407
+ rmSync(dir, { recursive: true, force: true });
408
+ }
409
+ }, 30_000);
410
+ });
@@ -137,6 +137,81 @@ describe('DDR-054 §2a — CI environment gate', () => {
137
137
  });
138
138
  });
139
139
 
140
+ /* ------------------------------------------- Phase 27 D2 — a cell owns its history */
141
+
142
+ describe('Cloud Phase 27 D2 — a workspace cell ignores the tenant´s linkedHub', () => {
143
+ let saved: string | undefined;
144
+
145
+ beforeEach(() => {
146
+ saved = process.env.MAUDE_WORKSPACE_MODE;
147
+ });
148
+ afterEach(() => {
149
+ if (saved === undefined) {
150
+ delete process.env.MAUDE_WORKSPACE_MODE;
151
+ } else process.env.MAUDE_WORKSPACE_MODE = saved;
152
+ });
153
+
154
+ function linkedCtx() {
155
+ const designRoot = join(dir, 'design');
156
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
157
+ return {
158
+ cfg: {
159
+ name: 't',
160
+ projectLabel: null,
161
+ designRoot: 'design',
162
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
163
+ rootClass: 'app',
164
+ themeDefault: 'dark' as const,
165
+ tokensCssRel: 'system/x.css',
166
+ teamAccentDefault: null,
167
+ handoffTargets: [],
168
+ newCanvasDir: 'ui',
169
+ newComponentDir: 'ui/components',
170
+ // The TENANT's committed config, carrying whatever hub their desktop
171
+ // was linked to. It is versioned, so it arrives in the cell with the
172
+ // checkout.
173
+ linkedHub: { url: 'https://someone-elses-hub.example.com', linkedAt: 1 },
174
+ _source: 'defaults' as const,
175
+ },
176
+ projectLabel: 't',
177
+ paths: {
178
+ repoRoot: dir,
179
+ designRel: 'design',
180
+ designRoot,
181
+ serverInfoFile: '',
182
+ activeFile: '',
183
+ commentsDir: join(designRoot, '_comments'),
184
+ canvasStateDir: '',
185
+ historyDir: '',
186
+ tokensUrlRel: '',
187
+ systemDirRel: 'system',
188
+ },
189
+ // biome-ignore lint/suspicious/noExplicitAny: minimal bus stub
190
+ bus: { on: () => () => {}, emit: () => {} } as any,
191
+ };
192
+ }
193
+
194
+ test('in a cell it refuses, and says which authority won', () => {
195
+ // Honouring the tenant's `linkedHub` inside a cell would dial OUT to a
196
+ // third-party hub carrying their canvases, and start a SECOND autocommit
197
+ // over the tree the hub is already committing. Both were unreachable by
198
+ // accident (no `~/.config/maude/hubs.json` in a cell), and an accident is
199
+ // not an invariant.
200
+ const warned: string[] = [];
201
+ const original = console.warn;
202
+ console.warn = (...args: unknown[]) => warned.push(args.map(String).join(' '));
203
+ process.env.MAUDE_WORKSPACE_MODE = '1';
204
+ try {
205
+ expect(createSyncRuntime(linkedCtx())).toBeNull();
206
+ // Not just null — null for THIS reason. Without the message this test
207
+ // would pass on the missing-token path it is meant to precede.
208
+ expect(warned.join(' ')).toContain('the hub owns history');
209
+ } finally {
210
+ console.warn = original;
211
+ }
212
+ });
213
+ });
214
+
140
215
  /* ---------------------------------------------------------------- §2b .tsx refusal */
141
216
 
142
217
  // Coverage moved to sync-runtime.test.ts > discoverCanvases >
@@ -18,8 +18,10 @@ import {
18
18
  FORBIDDEN_ROUTE_PREFIXES,
19
19
  formatContainmentFailure,
20
20
  isForbiddenRoute,
21
+ isSandboxedRoute,
21
22
  isWorkspaceMode,
22
23
  pruneForWorkspace,
24
+ SANDBOXED_ROUTE_PREFIXES,
23
25
  } from '../workspace-mode.ts';
24
26
 
25
27
  const CELL = { MAUDE_WORKSPACE_MODE: '1' } as NodeJS.ProcessEnv;
@@ -73,8 +75,8 @@ describe('checkContainment — what a cell may serve', () => {
73
75
  for (const route of [
74
76
  '/_api/export-jobs',
75
77
  '/_api/export/png',
76
- '/_canvas-shell.html',
77
- '/_canvas-runtime/react.js',
78
+ '/_api/github/repos',
79
+ '/_api/claude/signin',
78
80
  '/_ws/acp',
79
81
  ]) {
80
82
  const report = checkContainment([...ALLOWED_ROUTES, route]);
@@ -145,18 +147,67 @@ describe('assertContainment — the boot gate', () => {
145
147
  test('the failure message names the surface, the reason, and the way out', () => {
146
148
  let message = '';
147
149
  try {
148
- assertContainment([...ALLOWED_ROUTES, '/_canvas-shell.html'], { env: CELL });
150
+ assertContainment([...ALLOWED_ROUTES, '/_api/export'], { env: CELL });
149
151
  } catch (err) {
150
152
  message = (err as Error).message;
151
153
  }
152
- expect(message).toContain('/_canvas-shell.html');
154
+ expect(message).toContain('/_api/export');
153
155
  expect(message).toContain('DDR-193');
154
- expect(message).toContain('executes a canvas module');
156
+ expect(message).toContain('headless browser');
155
157
  // The escape hatch is named so the next engineer doesn't invent a flag.
156
158
  expect(message).toContain('Direction B');
157
159
  expect(message).toContain('HARD PREREQUISITE');
158
160
  });
159
161
 
162
+ // ---- DDR-209 A′1: the sandboxed lane -----------------------------------
163
+ //
164
+ // `/_canvas-shell` and `/_canvas-runtime` used to be on the forbidden list.
165
+ // They are not forbidden and they are not free: a cell may serve them ONLY
166
+ // while the out-of-process build sandbox is armed. These three tests are the
167
+ // difference between a reclassification and a loosening.
168
+
169
+ test('a canvas surface is REFUSED when the build sandbox is not armed', () => {
170
+ const report = checkContainment([...ALLOWED_ROUTES, '/_canvas-shell.html'], {
171
+ sandboxArmed: false,
172
+ });
173
+ expect(report.ok).toBe(false);
174
+ expect(report.unattested[0]?.route).toBe('/_canvas-shell.html');
175
+ // And it is NOT reported as forbidden — the two failures have different
176
+ // fixes, and collapsing them would send someone to delete a route that is
177
+ // supposed to be there.
178
+ expect(report.routes).toEqual([]);
179
+ });
180
+
181
+ test('a canvas surface is PERMITTED once the sandbox is armed', () => {
182
+ const report = checkContainment(
183
+ [...ALLOWED_ROUTES, '/_canvas-shell.html', '/_canvas-runtime/react.js'],
184
+ { sandboxArmed: true }
185
+ );
186
+ expect(report.ok).toBe(true);
187
+ });
188
+
189
+ test('the attestation defaults to UNMET — an unstated contract is not a contract', () => {
190
+ expect(checkContainment(['/_canvas-shell.html']).ok).toBe(false);
191
+ });
192
+
193
+ test('arming the sandbox does not permit anything on the FORBIDDEN list', () => {
194
+ // The failure this rules out: `sandboxArmed: true` becoming a master key.
195
+ const report = checkContainment([...ALLOWED_ROUTES, '/_api/export', '/_ws/acp'], {
196
+ sandboxArmed: true,
197
+ });
198
+ expect(report.ok).toBe(false);
199
+ expect(report.routes.map((r) => r.route).sort()).toEqual(['/_api/export', '/_ws/acp']);
200
+ });
201
+
202
+ test('the unattested message explains the contract rather than just refusing', () => {
203
+ const message = formatContainmentFailure(
204
+ checkContainment(['/_canvas-shell.html'], { sandboxArmed: false })
205
+ );
206
+ expect(message).toContain('BUILD SANDBOX is not armed');
207
+ expect(message).toContain('EMPTY environment');
208
+ expect(message).toContain('DDR-209');
209
+ });
210
+
160
211
  test('formatContainmentFailure reports routes and modules together', () => {
161
212
  const report = checkContainment([...ALLOWED_ROUTES, '/_api/export'], {
162
213
  resolveModule: (s) => s === 'puppeteer',
@@ -175,13 +226,34 @@ describe('the vocabulary itself', () => {
175
226
  // quietly dropping an entry while every other test stays green. Changing
176
227
  // this list should require changing this test, on purpose.
177
228
  expect(FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix).sort()).toEqual([
229
+ // Surfaces that EVALUATE tenant content on our compute…
230
+ '/_api/acp',
231
+ '/_api/claude',
232
+ '/_api/cloud',
233
+ '/_api/debug-bundle',
234
+ '/_api/design',
178
235
  '/_api/export',
179
236
  '/_api/generate',
237
+ '/_api/github',
238
+ '/_api/hub',
180
239
  '/_api/photo-edit',
240
+ // Spawns a headless browser against the studio — same evaluation as
241
+ // `/_api/export`, and there is no browser in a cell image.
242
+ '/_api/shell-shot',
243
+ '/_ws/acp',
244
+ ]);
245
+ // …and the two that a cell SERVES, under an asserted contract (DDR-209 A′1).
246
+ // They moved lists; they did not stop being checked. See the sandbox tests
247
+ // above for what "under contract" is worth.
248
+ expect(SANDBOXED_ROUTE_PREFIXES.map((f) => f.prefix).sort()).toEqual([
181
249
  '/_canvas-runtime',
182
250
  '/_canvas-shell',
183
- '/_ws/acp',
184
251
  ]);
252
+ // Nothing may be on both lists — that would make the weaker one decorative.
253
+ const forbidden = new Set(FORBIDDEN_ROUTE_PREFIXES.map((f) => f.prefix));
254
+ for (const { prefix } of SANDBOXED_ROUTE_PREFIXES) {
255
+ expect(forbidden.has(prefix)).toBe(false);
256
+ }
185
257
  expect([...FORBIDDEN_MODULES].sort()).toEqual([
186
258
  'playwright',
187
259
  'playwright-core',
@@ -245,14 +317,21 @@ describe('pruneForWorkspace — how a cell can boot at all', () => {
245
317
  });
246
318
 
247
319
  test('isForbiddenRoute is the single predicate both the table and fetch use', () => {
248
- // The `fetch` fall-through owns paths that are not in the route table
249
- // (/_ws/acp, /_canvas-shell.html, /_canvas-runtime/*), so it gates on this
250
- // same function. One predicate, so the two gates cannot disagree.
251
- expect(isForbiddenRoute('/_canvas-shell.html')).toBe(true);
252
- expect(isForbiddenRoute('/_canvas-runtime/react.js')).toBe(true);
320
+ // The `fetch` fall-through owns `/_ws/acp`, which is not in the route table,
321
+ // so it gates on this same function. One predicate, so the two gates cannot
322
+ // disagree.
253
323
  expect(isForbiddenRoute('/_ws/acp')).toBe(true);
324
+ expect(isForbiddenRoute('/_api/github/repos')).toBe(true);
254
325
  expect(isForbiddenRoute('/_ws')).toBe(false);
255
326
  expect(isForbiddenRoute('/_health')).toBe(false);
256
327
  expect(isForbiddenRoute('/_api/asset')).toBe(false);
328
+ // DDR-209 A′1 — the canvas surfaces are NOT forbidden, which is exactly why
329
+ // the fall-through stopped 404-ing them. `isSandboxedRoute` is where they
330
+ // are now accounted for.
331
+ expect(isForbiddenRoute('/_canvas-shell.html')).toBe(false);
332
+ expect(isForbiddenRoute('/_canvas-runtime/react.js')).toBe(false);
333
+ expect(isSandboxedRoute('/_canvas-shell.html')).toBe(true);
334
+ expect(isSandboxedRoute('/_canvas-runtime/react.js')).toBe(true);
335
+ expect(isSandboxedRoute('/_health')).toBe(false);
257
336
  });
258
337
  });
@@ -592,6 +592,13 @@ function createSession(slug: string): CollabSession {
592
592
  let cancelled = false;
593
593
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
594
594
  let wsRef: WebSocket | null = null;
595
+ // Consecutive failed connects. A server that refuses the upgrade (403/401 —
596
+ // e.g. a deployment whose proxy does not carry the collab lane) used to be
597
+ // retried every second, forever, silently — which is indistinguishable from
598
+ // "nobody else is here" (the cloud-collab RCA shipped invisible exactly this
599
+ // way). Back off exponentially and say so once.
600
+ let failedConnects = 0;
601
+ let failureWarned = false;
595
602
 
596
603
  function sendFrame(ws: WebSocket, payload: Uint8Array) {
597
604
  try {
@@ -624,6 +631,8 @@ function createSession(slug: string): CollabSession {
624
631
  wsRef = ws;
625
632
 
626
633
  ws.addEventListener('open', () => {
634
+ failedConnects = 0;
635
+ failureWarned = false;
627
636
  session.connected = true;
628
637
  notifySession(session);
629
638
  // Sync step 1 — announce our state vector so the server can send the
@@ -637,11 +646,22 @@ function createSession(slug: string): CollabSession {
637
646
  });
638
647
 
639
648
  ws.addEventListener('close', () => {
649
+ const hadConnected = session.connected;
640
650
  session.connected = false;
641
651
  notifySession(session);
642
652
  wsRef = null;
643
653
  if (cancelled) return;
644
- reconnectTimer = setTimeout(connect, 1000);
654
+ failedConnects = hadConnected ? 0 : failedConnects + 1;
655
+ if (failedConnects >= 5 && !failureWarned) {
656
+ failureWarned = true;
657
+ console.warn(
658
+ '[collab] live sync unavailable — the collab socket keeps being refused. ' +
659
+ 'Presence, live annotations and cross-peer updates are off until it connects.'
660
+ );
661
+ }
662
+ // 1s → 2s → 4s → … capped at 15s; a healthy drop (was connected) retries fast.
663
+ const delay = Math.min(1000 * 2 ** Math.min(failedConnects, 4), 15000);
664
+ reconnectTimer = setTimeout(connect, delay);
645
665
  });
646
666
 
647
667
  ws.addEventListener('error', () => {
@@ -1,13 +1,58 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "your-own-place-in-a-shared-project",
6
+ "version": "0.54.0",
7
+ "date": "2026-08-04",
8
+ "kind": "improvement",
9
+ "title": "A shared project keeps your place",
10
+ "summary": "Open a cloud project and you land on a canvas, not an empty pane, with one line telling you what you can do there. Your open canvas and your pan and zoom are now yours alone — a teammate moving around no longer moves you. And reading a project no longer means reading it blind: reviewers can open the Inspector and Layers panels, which quietly refused to appear before.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "the-real-studio-in-your-browser",
15
+ "version": "0.54.0",
16
+ "date": "2026-08-04",
17
+ "kind": "feature",
18
+ "title": "The full studio, in your browser",
19
+ "summary": "A cloud project opened in a browser now gives you the same application as the desktop — Files, Layers, Inspector, search, the toolbar and the live status — and your designs look like your designs: photographs, logos and webfonts load on the canvas. The browser tab also names the account you are signed in as, and lets you sign out.",
20
+ "surface": "design-ui"
21
+ },
22
+ {
23
+ "id": "design-system-component-styles-load",
24
+ "version": "0.54.0",
25
+ "date": "2026-08-04",
26
+ "kind": "fix",
27
+ "title": "Design-system styles render on canvases",
28
+ "summary": "Canvases in a project with a bootstrapped design system were drawing without their component styles — buttons, cards, inputs and the rest fell back to unstyled defaults. They render correctly again, on the desktop as well as in the browser.",
29
+ "surface": "design-ui"
30
+ },
31
+ {
32
+ "id": "cloud-live-collaboration-connects",
33
+ "version": "0.54.0",
34
+ "date": "2026-08-04",
35
+ "kind": "fix",
36
+ "title": "Live collaboration works in the browser",
37
+ "summary": "Opening a cloud project in two browsers now actually collaborates: you see each other's cursors with names, annotations and comments appear live for everyone, and every change is saved to the project. Before, the live channel never connected — selections crossed over anonymously and annotations silently stayed on the machine that drew them.",
38
+ "surface": "design-ui"
39
+ },
40
+ {
41
+ "id": "bug-reports-see-the-maude-window",
42
+ "version": "0.53.2",
43
+ "kind": "improvement",
44
+ "title": "Bug reports now show the Maude window",
45
+ "summary": "Report a bug used to attach only the artboard, so anything wrong with Maude itself — the menus, the sidebar, a panel — was never in the picture. It now captures the whole window too, and you can attach your own screenshot for whatever a fresh capture can't show, like an open menu or a stuck spinner.",
46
+ "surface": "design-ui",
47
+ "date": "2026-08-03"
48
+ },
4
49
  {
5
50
  "id": "open-your-project-in-a-browser",
6
51
  "version": "0.53.0",
7
52
  "date": "2026-08-02",
8
53
  "kind": "feature",
9
- "title": "Open your project in a browser — nothing to install",
10
- "summary": "Send a teammate a link and they open the real project in their browser: the actual canvases, the actual design system. They can move things, edit text and colours, leave notes, and download the whole thing — signed in with their Maude account, with the same rights they have in the app.",
54
+ "title": "Open your project in a browser — early access",
55
+ "summary": "Send a teammate a link and they open the project in their browser, signed in with their Maude account and with the rights their role gives them. This first release shows a simplified view: the full Maude Studio — Files, Layers, Inspector, search — and your project's own images and fonts are coming next.",
11
56
  "surface": "design-ui"
12
57
  },
13
58
  {