@1agh/maude 0.23.0 → 0.24.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 (101) hide show
  1. package/README.md +34 -1
  2. package/cli/bin/maude.mjs +3 -0
  3. package/cli/commands/cache.mjs +181 -0
  4. package/cli/commands/cache.test.mjs +166 -0
  5. package/cli/commands/design-link.test.mjs +32 -2
  6. package/cli/commands/design.mjs +95 -4
  7. package/cli/commands/design.test.mjs +56 -0
  8. package/cli/commands/help.mjs +34 -0
  9. package/cli/commands/hub.mjs +255 -30
  10. package/cli/commands/hub.test.mjs +126 -2
  11. package/cli/commands/init.mjs +3 -0
  12. package/cli/commands/preflight.mjs +11 -0
  13. package/cli/commands/preflight.test.mjs +46 -0
  14. package/cli/commands/scenario-report.mjs +45 -0
  15. package/cli/lib/cache.mjs +407 -0
  16. package/cli/lib/cache.test.mjs +303 -0
  17. package/cli/lib/design-link.mjs +74 -10
  18. package/cli/lib/flow-design-integration.test.mjs +165 -0
  19. package/cli/lib/gitignore-block.mjs +90 -0
  20. package/cli/lib/gitignore-block.test.mjs +123 -0
  21. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  22. package/cli/lib/preflight.mjs +41 -10
  23. package/package.json +8 -8
  24. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  25. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  26. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  27. package/plugins/design/dev-server/api.ts +109 -4
  28. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  29. package/plugins/design/dev-server/bin/prep.sh +211 -0
  30. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  31. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  32. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  33. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  34. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  35. package/plugins/design/dev-server/canvas-lib.tsx +25 -21
  36. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  37. package/plugins/design/dev-server/canvas-shell.tsx +333 -19
  38. package/plugins/design/dev-server/client/app.jsx +155 -7
  39. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  40. package/plugins/design/dev-server/collab/index.ts +87 -9
  41. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  42. package/plugins/design/dev-server/collab/registry.ts +80 -2
  43. package/plugins/design/dev-server/collab/room.ts +21 -8
  44. package/plugins/design/dev-server/context-menu.tsx +167 -23
  45. package/plugins/design/dev-server/context.ts +24 -0
  46. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  47. package/plugins/design/dev-server/dist/client.bundle.js +126 -13
  48. package/plugins/design/dev-server/dist/comment-mount.js +78 -11
  49. package/plugins/design/dev-server/dist/styles.css +16 -0
  50. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  51. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  52. package/plugins/design/dev-server/fs-watch.ts +1 -0
  53. package/plugins/design/dev-server/http.ts +260 -20
  54. package/plugins/design/dev-server/input-router.tsx +26 -3
  55. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  56. package/plugins/design/dev-server/server.ts +123 -1
  57. package/plugins/design/dev-server/sync/agent.ts +95 -0
  58. package/plugins/design/dev-server/sync/codec.ts +155 -0
  59. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  60. package/plugins/design/dev-server/sync/index.ts +479 -35
  61. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  62. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  63. package/plugins/design/dev-server/sync/origins.ts +57 -0
  64. package/plugins/design/dev-server/sync/projection.ts +368 -0
  65. package/plugins/design/dev-server/sync/status.ts +115 -0
  66. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  67. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  68. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  69. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  70. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  71. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  72. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  73. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  74. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  75. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  77. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  78. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  79. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  80. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  81. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  82. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  83. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  84. package/plugins/design/dev-server/test/sync-runtime.test.ts +531 -4
  85. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  86. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  87. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  88. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  89. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  90. package/plugins/design/dev-server/undo-stack.ts +20 -4
  91. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  92. package/plugins/design/dev-server/use-tool-mode.tsx +15 -12
  93. package/plugins/design/dev-server/ws.ts +40 -1
  94. package/plugins/design/templates/_shell.html +49 -1
  95. package/plugins/design/templates/canvas.tsx.template +13 -0
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +14 -7
  97. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  98. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  99. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  100. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  101. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -36,6 +36,14 @@ await bootSelfHeal();
36
36
 
37
37
  const ctx = createContext();
38
38
 
39
+ // Phase 9.2 (DDR-064) — `MAUDE_SHARED_DOC` feature flag. OPT-IN (default OFF),
40
+ // the inverse of MAUDE_CANVAS_ORIGIN_SPLIT's opt-out parsing: only an explicit
41
+ // truthy value enables the single-shared-doc path. OFF = the proven two-doc +
42
+ // disk-reconcile path = byte-for-byte current behavior. The flag is threaded
43
+ // onto ctx here (before createCollab / createSyncRuntime read it) so every
44
+ // downstream consumer sees one source of truth.
45
+ ctx.sharedDoc = /^(1|true|on|yes)$/i.test(process.env.MAUDE_SHARED_DOC ?? '');
46
+
39
47
  // Forward-declared so the api.commentsAdd/patch/delete/addReply callback can
40
48
  // reach into the collab registry (Phase 8 Task 3 bridge). collab is initialized
41
49
  // synchronously below; the callback only fires at runtime, by which point the
@@ -142,6 +150,87 @@ function startServer(port: number): BunServer {
142
150
  });
143
151
  }
144
152
 
153
+ // T2 (9.1-A) — the segregated canvas-content origin. A second Bun.serve on its
154
+ // own (OS-assigned) port, sharing this process's ctx / api / inspect / collab /
155
+ // ws. Canvas iframes load from here; hub-pushed JSX that executes in a canvas
156
+ // can therefore only reach THIS origin (locked further by the CSP on the shell
157
+ // + an iframe sandbox), never the main origin's /_api/export, /_config,
158
+ // /_sync-status, /_comments, or arbitrary repo files. Routes are a hard
159
+ // allowlist: Bun matches `routes` before `fetch`, so we expose ONLY the two
160
+ // gated API endpoints the runtime needs here and 403 everything else in fetch.
161
+ function startCanvasServer(port: number): BunServer {
162
+ return Bun.serve<WsData, never>({
163
+ port,
164
+ hostname: '127.0.0.1',
165
+ development: process.env.NODE_ENV !== 'production',
166
+ // Hard allowlist of route-table endpoints (Bun matches `routes` before
167
+ // `fetch`). Only the collab/display-data endpoints the canvas runtime needs
168
+ // — see http.isCanvasSafeRoute for the trust rationale. The dynamic
169
+ // /_api/comments/<id>/reply POST is fetch-handled + gated there.
170
+ routes: {
171
+ '/_health': http.routes['/_health'],
172
+ '/_api/git-user': http.routes['/_api/git-user'],
173
+ '/_api/canvas-meta': http.routes['/_api/canvas-meta'],
174
+ '/_api/annotations': http.routes['/_api/annotations'],
175
+ '/_api/git-committers': http.routes['/_api/git-committers'],
176
+ '/_api/ai': http.routes['/_api/ai'],
177
+ '/_comments': http.routes['/_comments'],
178
+ },
179
+ async fetch(req, srv) {
180
+ const pathname = new URL(req.url).pathname;
181
+
182
+ // Collab WS — shared registry, loopback-only (same gate as the main origin).
183
+ const collabSlug = parseCollabSlug(pathname);
184
+ if (collabSlug !== null) {
185
+ if (!isLoopbackHost(req.headers.get('host'))) {
186
+ return new Response('cross-machine collab requires Phase 9 hub deploy', { status: 403 });
187
+ }
188
+ const ok = srv.upgrade(req, {
189
+ data: {
190
+ id: crypto.randomUUID(),
191
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
192
+ kind: 'collab',
193
+ slug: collabSlug,
194
+ },
195
+ });
196
+ if (ok) return undefined as unknown as Response;
197
+ return new Response('Upgrade failed', { status: 400 });
198
+ }
199
+
200
+ // HMR-only socket — canvas iframes listen for `canvas-hmr` here. Carries
201
+ // NO privileged inspector feed and ignores inbound messages (ws.ts).
202
+ if (pathname.startsWith('/_ws')) {
203
+ const ok = srv.upgrade(req, {
204
+ data: {
205
+ id: crypto.randomUUID(),
206
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
207
+ kind: 'canvas-hmr',
208
+ },
209
+ });
210
+ if (ok) return undefined as unknown as Response;
211
+ return new Response('Upgrade failed', { status: 400 });
212
+ }
213
+
214
+ // Canvas mount harness with the strict CSP ALWAYS on (F1 gate).
215
+ if (pathname === '/_canvas-shell.html' || pathname === '/_canvas-shell') {
216
+ return http.serveCanvasShell(true);
217
+ }
218
+
219
+ // Allowlist gate — runtime bundles, comment-mount, transpiled .tsx + CSS/
220
+ // assets under designRoot. Everything else is refused at the door.
221
+ if (!http.isCanvasSafeRoute(pathname)) {
222
+ return new Response('Forbidden (canvas origin)', { status: 403 });
223
+ }
224
+ return http.fetch(req);
225
+ },
226
+ websocket: ws.handler,
227
+ error(e) {
228
+ console.error('[bun.serve canvas-origin error]', e);
229
+ return new Response('Server error', { status: 500 });
230
+ },
231
+ });
232
+ }
233
+
145
234
  function isAddrInUse(e: unknown): boolean {
146
235
  if (!e || typeof e !== 'object') return false;
147
236
  const err = e as { code?: string; errno?: number };
@@ -181,6 +270,30 @@ let server: BunServer;
181
270
  server = bound;
182
271
  }
183
272
 
273
+ // T2 (9.1-A) — advertise the main origin so the canvas origin's CSP can
274
+ // allowlist it in `frame-ancestors` (the legit embedder). Must be set before
275
+ // the canvas listener serves its first shell.
276
+ ctx.mainOrigin = `http://localhost:${server.port}`;
277
+
278
+ // T2 (9.1-A) — segregated canvas-content origin. ON BY DEFAULT (opt-OUT) since
279
+ // phase-9.1: a second listener binds an OS-assigned free port, advertised as
280
+ // `canvasOrigin`, and the client loads canvas iframes cross-origin under the
281
+ // strict CSP + sandbox + route-allowlist (the F1 containment). This is purely
282
+ // protective — for a solo user it sandboxes their OWN canvas code (no untrusted
283
+ // content, no exfil concern), and interactive-feature parity (selection,
284
+ // comments, presence, motion) is verified. It does NOT by itself enable
285
+ // untrusted `.tsx` sync — that still requires the per-canvas `syncable` opt-in
286
+ // (sync/index.ts), so the WebRTC/self-nav exfil residual only applies to a
287
+ // canvas explicitly opted into syncing (DDR-060 + the F1 re-audit report).
288
+ // Set `MAUDE_CANVAS_ORIGIN_SPLIT=0` (or false/off/no) to fall back to the legacy
289
+ // same-origin path.
290
+ const CANVAS_ORIGIN_SPLIT = !/^(0|false|off|no)$/i.test(
291
+ process.env.MAUDE_CANVAS_ORIGIN_SPLIT ?? ''
292
+ );
293
+ const canvasServer = CANVAS_ORIGIN_SPLIT ? startCanvasServer(0) : null;
294
+ const canvasOrigin = canvasServer ? `http://localhost:${canvasServer.port}` : undefined;
295
+ if (canvasOrigin) ctx.canvasOrigin = canvasOrigin;
296
+
184
297
  await Bun.write(
185
298
  ctx.paths.serverInfoFile,
186
299
  JSON.stringify(
@@ -188,6 +301,7 @@ await Bun.write(
188
301
  pid: process.pid,
189
302
  port: server.port,
190
303
  url: `http://localhost:${server.port}`,
304
+ ...(canvasOrigin ? { canvasOrigin } : {}),
191
305
  started: new Date().toISOString(),
192
306
  project: ctx.cfg.name,
193
307
  config_source: ctx.cfg._source,
@@ -238,7 +352,10 @@ async function shutdown() {
238
352
  /* best-effort — provider sockets will be closed by process exit anyway */
239
353
  }
240
354
  try {
241
- if (collab) await collab.registry.destroyAll();
355
+ if (collab) {
356
+ collab.dispose();
357
+ await collab.registry.destroyAll();
358
+ }
242
359
  } catch {
243
360
  /* best-effort flush; the JSON snapshot is the ground truth anyway */
244
361
  }
@@ -261,6 +378,11 @@ async function shutdown() {
261
378
  /* ignore */
262
379
  }
263
380
  server.stop();
381
+ try {
382
+ canvasServer?.stop();
383
+ } catch {
384
+ /* best-effort */
385
+ }
264
386
  process.exit(0);
265
387
  }
266
388
 
@@ -37,9 +37,14 @@ import {
37
37
  annotationsFromDoc,
38
38
  applyAnnotationsToDoc,
39
39
  applyCommentsToDoc,
40
+ applyCssToDoc,
40
41
  applyHtmlToDoc,
42
+ applyMetaToDoc,
41
43
  commentsFromDoc,
44
+ cssFromDoc,
42
45
  htmlFromDoc,
46
+ mergeSharedMetaIntoLocal,
47
+ metaFromDoc,
43
48
  } from './codec.ts';
44
49
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
45
50
 
@@ -52,6 +57,14 @@ export interface CanvasSyncPaths {
52
57
  comments: string;
53
58
  /** Absolute path to <designRoot>/<slug>.annotations.svg. */
54
59
  annotations: string;
60
+ /** Absolute path to the canvas `.meta.json` (sibling of the body). Optional:
61
+ * when set (always, in production wiring), shared meta keys (layout/artboards)
62
+ * sync while per-user viewport stays local (Phase 9.1 Gap 2). Omitted in
63
+ * older test constructions → meta sync is simply inert. */
64
+ meta?: string;
65
+ /** Absolute path to the canvas's sibling `.css` (Phase 9.1 Gap 3). Optional —
66
+ * inline-CSS canvases have none; omitted/absent → css sync is inert. */
67
+ css?: string;
55
68
  }
56
69
 
57
70
  export interface CanvasSyncAgentOptions {
@@ -66,6 +79,14 @@ export interface CanvasSyncAgentOptions {
66
79
  flushMs?: number;
67
80
  /** Injected for tests — defaults to atomicWrite. */
68
81
  writer?: (path: string, bytes: string | Uint8Array) => void;
82
+ /**
83
+ * Called when a non-adopt reconcile (cold-start / post-git-pull) overwrites
84
+ * differing, non-empty local content with hub state — i.e. the local peer
85
+ * had divergent work that hub-wins discarded. Lets the runtime surface a
86
+ * "hub overwrote your local changes" notification (Phase 9 Task 8). v1.1
87
+ * resolution is always hub-wins; the interactive 3-way prompt is deferred.
88
+ */
89
+ onConflict?: (info: { slug: string; kind: 'cold-start-hub-wins' }) => void;
69
90
  }
70
91
 
71
92
  export interface CanvasSyncAgent {
@@ -105,6 +126,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
105
126
  let lastHtml: string | null = null;
106
127
  let lastComments: string | null = null;
107
128
  let lastAnnotations: string | null = null;
129
+ let lastMeta: string | null = null;
130
+ let lastCss: string | null = null;
108
131
 
109
132
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
110
133
  if (stopped) return;
@@ -141,6 +164,8 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
141
164
  writeHtmlIfChanged();
142
165
  writeCommentsIfChanged();
143
166
  writeAnnotationsIfChanged();
167
+ writeMetaIfChanged();
168
+ writeCssIfChanged();
144
169
  } catch (err) {
145
170
  dirty = true;
146
171
  console.error(`[sync/${slug}] flush failed:`, err);
@@ -186,6 +211,31 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
186
211
  lastAnnotations = value;
187
212
  }
188
213
 
214
+ function writeMetaIfChanged(): void {
215
+ if (!paths.meta) return;
216
+ const shared = metaFromDoc(doc);
217
+ if (shared === lastMeta) return;
218
+ lastMeta = shared;
219
+ if (shared === null) return; // doc carries no shared meta yet — nothing to merge down
220
+ const local = readLocal(paths.meta);
221
+ const merged = mergeSharedMetaIntoLocal(local, shared);
222
+ if (merged === null || merged === local) return; // unparseable, or disk already matches
223
+ const hash = hashBytes(merged);
224
+ echoGuard.record(paths.meta, hash);
225
+ writer(paths.meta, merged);
226
+ }
227
+
228
+ function writeCssIfChanged(): void {
229
+ if (!paths.css) return;
230
+ const next = cssFromDoc(doc);
231
+ if (next === lastCss) return;
232
+ lastCss = next;
233
+ if (next === null) return; // doc carries no css yet — nothing to write
234
+ const hash = hashBytes(next);
235
+ echoGuard.record(paths.css, hash);
236
+ writer(paths.css, next);
237
+ }
238
+
189
239
  function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
190
240
  if (stopped) return false;
191
241
  // Echo of our own atomicWrite — drop.
@@ -209,6 +259,18 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
209
259
  if (changed) lastAnnotations = str;
210
260
  return changed;
211
261
  }
262
+ if (paths.meta && evt.path === paths.meta) {
263
+ // Local meta changed (canvas-meta PATCH / design:edit) → push its SHARED
264
+ // subset (layout/artboards, minus per-user viewport) into the doc.
265
+ const changed = applyMetaToDoc(doc, str, origin);
266
+ if (changed) lastMeta = metaFromDoc(doc);
267
+ return changed;
268
+ }
269
+ if (paths.css && evt.path === paths.css) {
270
+ const changed = applyCssToDoc(doc, str, origin);
271
+ if (changed) lastCss = str;
272
+ return changed;
273
+ }
212
274
  return false;
213
275
  }
214
276
 
@@ -217,12 +279,16 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
217
279
  const localHtml = readLocal(paths.html);
218
280
  const localComments = readLocal(paths.comments);
219
281
  const localAnnotations = readLocal(paths.annotations);
282
+ const localMeta = paths.meta ? readLocal(paths.meta) : null;
283
+ const localCss = paths.css ? readLocal(paths.css) : null;
220
284
 
221
285
  const docHtml = htmlFromDoc(doc);
222
286
  const docComments = commentsFromDoc(doc);
223
287
  const docCommentsStr =
224
288
  docComments.length > 0 ? `${JSON.stringify(docComments, null, 2)}\n` : '';
225
289
  const docAnnotations = annotationsFromDoc(doc) ?? '';
290
+ const docMeta = metaFromDoc(doc);
291
+ const docCss = cssFromDoc(doc);
226
292
 
227
293
  if (adopt) {
228
294
  // Push local up: doc takes its values from disk. Hub becomes our
@@ -233,9 +299,13 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
233
299
  if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
234
300
  }
235
301
  if (localAnnotations !== null) applyAnnotationsToDoc(doc, localAnnotations, origin);
302
+ if (paths.meta && localMeta !== null) applyMetaToDoc(doc, localMeta, origin);
303
+ if (paths.css && localCss !== null) applyCssToDoc(doc, localCss, origin);
236
304
  lastHtml = localHtml ?? '';
237
305
  lastComments = localComments ?? '';
238
306
  lastAnnotations = localAnnotations ?? '';
307
+ lastMeta = metaFromDoc(doc);
308
+ lastCss = cssFromDoc(doc);
239
309
  adopt = false;
240
310
  return;
241
311
  }
@@ -244,7 +314,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
244
314
  lastHtml = docHtml;
245
315
  lastComments = docCommentsStr;
246
316
  lastAnnotations = docAnnotations;
317
+ lastMeta = docMeta;
318
+ lastCss = docCss;
247
319
  if (localHtml !== docHtml) {
320
+ // Local had divergent, non-empty content that hub-wins is discarding —
321
+ // notify so the user knows their local edits were overwritten (Task 8).
322
+ // An absent/empty local file is a clean first-sync, not a conflict.
323
+ if (localHtml !== null && localHtml.trim() !== '') {
324
+ opts.onConflict?.({ slug, kind: 'cold-start-hub-wins' });
325
+ }
248
326
  const hash = hashBytes(docHtml);
249
327
  echoGuard.record(paths.html, hash);
250
328
  writer(paths.html, docHtml);
@@ -259,6 +337,23 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
259
337
  echoGuard.record(paths.annotations, hash);
260
338
  writer(paths.annotations, docAnnotations);
261
339
  }
340
+ // Meta: merge the doc's shared subset (layout/artboards) into local,
341
+ // preserving this machine's viewport + syncable. Only writes when the merge
342
+ // actually changes the file (a fresh peer with no local viewport, or an
343
+ // artboard layout the hub carries that local lacks).
344
+ if (paths.meta && docMeta !== null) {
345
+ const merged = mergeSharedMetaIntoLocal(localMeta, docMeta);
346
+ if (merged !== null && merged !== localMeta) {
347
+ const hash = hashBytes(merged);
348
+ echoGuard.record(paths.meta, hash);
349
+ writer(paths.meta, merged);
350
+ }
351
+ }
352
+ if (paths.css && docCss !== null && localCss !== docCss) {
353
+ const hash = hashBytes(docCss);
354
+ echoGuard.record(paths.css, hash);
355
+ writer(paths.css, docCss);
356
+ }
262
357
  }
263
358
 
264
359
  return {
@@ -33,6 +33,16 @@ import { Y_TYPES } from '../collab/persistence.ts';
33
33
  export const Y_SYNC_TYPES = {
34
34
  /** The canvas HTML body, as opaque Y.Text. */
35
35
  html: 'html',
36
+ /** The canvas's sibling `.css` (e.g. `Kanban App.css`), as opaque Y.Text.
37
+ * Edited via files (design:edit), not a live browser surface, so it mirrors
38
+ * wholesale like the body — no per-user keys, no room-clobber. Phase 9.1
39
+ * Gap 3. */
40
+ css: 'css',
41
+ /** The canvas `.meta.json` SHARED subset (layout/artboards/structure) as an
42
+ * opaque canonical-JSON Y.Text. Per-user keys (viewport pan/zoom) + the
43
+ * security opt-in (syncable) are stripped before sync — see META_LOCAL_KEYS
44
+ * + sharedMetaCanonical. Phase 9.1 Gap 2. */
45
+ meta: 'meta',
36
46
  } as const;
37
47
 
38
48
  /**
@@ -44,11 +54,26 @@ export const Y_SYNC_TYPES = {
44
54
  export const MAX_HTML_BYTES = 4 * 1024 * 1024;
45
55
  export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
46
56
  export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
57
+ export const MAX_META_BYTES = 1 * 1024 * 1024;
58
+ export const MAX_CSS_BYTES = 4 * 1024 * 1024;
47
59
 
48
60
  function byteLengthUtf8(s: string): number {
49
61
  return Buffer.byteLength(s, 'utf8');
50
62
  }
51
63
 
64
+ // DDR-054 §2g — strip dangerous keys at parse time so a hostile hub-pushed (or
65
+ // planted-commit) payload can't seed `__proto__` / `constructor` / `prototype`
66
+ // own-properties that yjs then serializes to other peers / writes to disk that
67
+ // Claude reads. Mirrors the agent's comments reviver; applied to the `.meta.json`
68
+ // parse paths (Phase D security re-audit finding A3 — the reviver had been
69
+ // applied only to the comments lane, not the symmetric meta lane).
70
+ function parseJsonSafe(s: string): unknown {
71
+ return JSON.parse(s, (key, value) => {
72
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') return undefined;
73
+ return value;
74
+ });
75
+ }
76
+
52
77
  /* ---------------------------------------------------------------- HTML */
53
78
 
54
79
  export function htmlFromDoc(doc: Y.Doc): string {
@@ -167,3 +192,133 @@ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?:
167
192
  }, origin);
168
193
  return true;
169
194
  }
195
+
196
+ /* ---------------------------------------------------------------- meta */
197
+
198
+ /**
199
+ * Keys of a canvas `.meta.json` that are PER-MACHINE and MUST NOT cross the hub:
200
+ * - `viewport` — this user's pan/zoom; syncing it would yank a
201
+ * collaborator's camera around on every pan.
202
+ * - `last_modified` — a local write timestamp; syncing it churns with no signal.
203
+ * - `syncable` — the security opt-in (DDR-054). A peer/hub must NEVER be
204
+ * able to flip another repo's sync gate, so it stays local
205
+ * and human-edited — same rationale that keeps it out of
206
+ * the /_api/canvas-meta PATCH whitelist.
207
+ * Everything else (title, sections/artboards, `layout` rects, css_mode, …) is
208
+ * shared canvas structure and DOES sync — that's how an artboard move on one
209
+ * machine reaches the other's disk.
210
+ */
211
+ export const META_LOCAL_KEYS = ['viewport', 'last_modified', 'syncable'] as const;
212
+
213
+ /** Canonical JSON of the SHARED subset of a parsed meta object: local keys
214
+ * dropped, remaining keys sorted so equal content always serializes byte-equal
215
+ * (stable → the no-op guards stay quiet and sync doesn't churn). */
216
+ function sharedMetaCanonical(meta: Record<string, unknown>): string {
217
+ const out: Record<string, unknown> = {};
218
+ for (const k of Object.keys(meta).sort()) {
219
+ if ((META_LOCAL_KEYS as readonly string[]).includes(k)) continue;
220
+ out[k] = meta[k];
221
+ }
222
+ return JSON.stringify(out);
223
+ }
224
+
225
+ /** The synced shared-meta JSON string held in the doc, or null when unset. */
226
+ export function metaFromDoc(doc: Y.Doc): string | null {
227
+ const s = doc.getText(Y_SYNC_TYPES.meta).toString();
228
+ return s.length > 0 ? s : null;
229
+ }
230
+
231
+ /**
232
+ * Apply a FULL `.meta.json` string to the doc as its shared subset: parse, strip
233
+ * per-user/security keys, store the canonical shared JSON in a Y.Text. Returns
234
+ * false on parse error / over-cap / no change.
235
+ */
236
+ export function applyMetaToDoc(doc: Y.Doc, fullMetaJson: string, origin?: unknown): boolean {
237
+ let obj: unknown;
238
+ try {
239
+ obj = parseJsonSafe(fullMetaJson);
240
+ } catch {
241
+ return false;
242
+ }
243
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return false;
244
+ const shared = sharedMetaCanonical(obj as Record<string, unknown>);
245
+ if (byteLengthUtf8(shared) > MAX_META_BYTES) {
246
+ console.warn(
247
+ `[sync/codec] refusing meta apply > ${MAX_META_BYTES} bytes (got ${byteLengthUtf8(shared)}). DDR-054 §2d.`
248
+ );
249
+ return false;
250
+ }
251
+ const t = doc.getText(Y_SYNC_TYPES.meta);
252
+ if (t.toString() === shared) return false;
253
+ doc.transact(() => {
254
+ if (t.length > 0) t.delete(0, t.length);
255
+ t.insert(0, shared);
256
+ }, origin);
257
+ return true;
258
+ }
259
+
260
+ /**
261
+ * Merge the synced shared-meta into a local `.meta.json` string, PRESERVING the
262
+ * local per-user/security keys (META_LOCAL_KEYS). Shared keys become exactly
263
+ * what the doc holds (so a deletion on the source propagates); the local keys
264
+ * are layered back on top. Returns the merged JSON (2-space, trailing newline)
265
+ * ready to write, or null when the shared payload is unparseable.
266
+ */
267
+ export function mergeSharedMetaIntoLocal(
268
+ localMetaJson: string | null,
269
+ sharedJson: string
270
+ ): string | null {
271
+ let shared: unknown;
272
+ try {
273
+ shared = parseJsonSafe(sharedJson);
274
+ } catch {
275
+ return null;
276
+ }
277
+ if (!shared || typeof shared !== 'object' || Array.isArray(shared)) return null;
278
+ let local: Record<string, unknown> = {};
279
+ if (localMetaJson) {
280
+ try {
281
+ const parsed = parseJsonSafe(localMetaJson);
282
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
283
+ local = parsed as Record<string, unknown>;
284
+ }
285
+ } catch {
286
+ /* unparseable local meta — treat as empty; the shared subset becomes the base */
287
+ }
288
+ }
289
+ const merged: Record<string, unknown> = { ...(shared as Record<string, unknown>) };
290
+ for (const k of META_LOCAL_KEYS) {
291
+ if (k in local) merged[k] = local[k];
292
+ }
293
+ return `${JSON.stringify(merged, null, 2)}\n`;
294
+ }
295
+
296
+ /* ---------------------------------------------------------------- css */
297
+
298
+ /** The synced canvas CSS string held in the doc, or null when unset/empty. */
299
+ export function cssFromDoc(doc: Y.Doc): string | null {
300
+ const s = doc.getText(Y_SYNC_TYPES.css).toString();
301
+ return s.length > 0 ? s : null;
302
+ }
303
+
304
+ /**
305
+ * Apply the canvas's `.css` to the doc as opaque Y.Text (wholesale replace).
306
+ * CSS round-trips byte-identically (we write exactly the doc string back), so a
307
+ * wholesale delete+insert can't churn; the equality short-circuit keeps it quiet
308
+ * when unchanged. Returns false on over-cap / no change.
309
+ */
310
+ export function applyCssToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
311
+ if (byteLengthUtf8(next) > MAX_CSS_BYTES) {
312
+ console.warn(
313
+ `[sync/codec] refusing CSS apply > ${MAX_CSS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
314
+ );
315
+ return false;
316
+ }
317
+ const t = doc.getText(Y_SYNC_TYPES.css);
318
+ if (t.toString() === next) return false;
319
+ doc.transact(() => {
320
+ if (t.length > 0) t.delete(0, t.length);
321
+ t.insert(0, next);
322
+ }, origin);
323
+ return true;
324
+ }