@1agh/maude 0.22.2 → 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 (125) 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 +84 -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 +135 -10
  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 +222 -11
  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/hubs-config.mjs +42 -4
  22. package/cli/lib/plugin-cli-reachability.test.mjs +56 -0
  23. package/cli/lib/preflight.mjs +41 -10
  24. package/package.json +8 -8
  25. package/plugins/design/dev-server/ai-banner.tsx +2 -2
  26. package/plugins/design/dev-server/annotations-context-toolbar.tsx +349 -112
  27. package/plugins/design/dev-server/annotations-layer.tsx +906 -137
  28. package/plugins/design/dev-server/api.ts +109 -4
  29. package/plugins/design/dev-server/bin/preflight.sh +21 -10
  30. package/plugins/design/dev-server/bin/prep.sh +211 -0
  31. package/plugins/design/dev-server/bin/scenario-report.mjs +209 -0
  32. package/plugins/design/dev-server/bin/screenshot.sh +18 -1
  33. package/plugins/design/dev-server/bin/smoke.sh +94 -11
  34. package/plugins/design/dev-server/build.ts +69 -3
  35. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  36. package/plugins/design/dev-server/canvas-cursors.ts +125 -0
  37. package/plugins/design/dev-server/canvas-icons.tsx +130 -0
  38. package/plugins/design/dev-server/canvas-lib.tsx +47 -27
  39. package/plugins/design/dev-server/canvas-meta.schema.json +20 -0
  40. package/plugins/design/dev-server/canvas-shell.tsx +358 -245
  41. package/plugins/design/dev-server/client/app.jsx +191 -21
  42. package/plugins/design/dev-server/client/styles/3-shell.css +10 -0
  43. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  44. package/plugins/design/dev-server/collab/index.ts +87 -9
  45. package/plugins/design/dev-server/collab/persistence.ts +34 -3
  46. package/plugins/design/dev-server/collab/registry.ts +131 -2
  47. package/plugins/design/dev-server/collab/room.ts +21 -8
  48. package/plugins/design/dev-server/config.schema.json +20 -0
  49. package/plugins/design/dev-server/context-menu.tsx +167 -23
  50. package/plugins/design/dev-server/context.ts +31 -0
  51. package/plugins/design/dev-server/contextual-toolbar.tsx +7 -7
  52. package/plugins/design/dev-server/dist/client.bundle.js +144 -22
  53. package/plugins/design/dev-server/dist/comment-mount.js +1868 -0
  54. package/plugins/design/dev-server/dist/styles.css +16 -0
  55. package/plugins/design/dev-server/dom-selection.ts +156 -0
  56. package/plugins/design/dev-server/equal-spacing-handles.tsx +1 -1
  57. package/plugins/design/dev-server/export-dialog.tsx +19 -17
  58. package/plugins/design/dev-server/fs-watch.ts +1 -0
  59. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  60. package/plugins/design/dev-server/http.ts +260 -20
  61. package/plugins/design/dev-server/input-router.tsx +125 -64
  62. package/plugins/design/dev-server/participants-chrome.tsx +10 -10
  63. package/plugins/design/dev-server/server.ts +141 -1
  64. package/plugins/design/dev-server/sync/agent.ts +418 -0
  65. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  66. package/plugins/design/dev-server/sync/codec.ts +324 -0
  67. package/plugins/design/dev-server/sync/connection-state.ts +203 -0
  68. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  69. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  70. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  71. package/plugins/design/dev-server/sync/index.ts +918 -0
  72. package/plugins/design/dev-server/sync/materialize.ts +62 -0
  73. package/plugins/design/dev-server/sync/migrate-seed.ts +163 -0
  74. package/plugins/design/dev-server/sync/origins.ts +57 -0
  75. package/plugins/design/dev-server/sync/projection.ts +368 -0
  76. package/plugins/design/dev-server/sync/status.ts +115 -0
  77. package/plugins/design/dev-server/sync/untrusted.ts +153 -0
  78. package/plugins/design/dev-server/test/_helpers.ts +6 -2
  79. package/plugins/design/dev-server/test/annotations-layer.test.ts +231 -0
  80. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +276 -0
  81. package/plugins/design/dev-server/test/canvas-cursors.test.ts +73 -0
  82. package/plugins/design/dev-server/test/canvas-origin-gate.test.ts +76 -0
  83. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  84. package/plugins/design/dev-server/test/collab-reseed-guard.test.ts +78 -0
  85. package/plugins/design/dev-server/test/collab-room.test.ts +21 -10
  86. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  87. package/plugins/design/dev-server/test/csp-canvas-shell.test.ts +46 -0
  88. package/plugins/design/dev-server/test/fixtures/phase-20-annotations.svg +1 -0
  89. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  90. package/plugins/design/dev-server/test/input-router.test.ts +21 -0
  91. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +100 -0
  92. package/plugins/design/dev-server/test/shared-doc-convergence.test.ts +265 -0
  93. package/plugins/design/dev-server/test/shared-doc-foundation.test.ts +63 -0
  94. package/plugins/design/dev-server/test/shared-doc-migrate.test.ts +160 -0
  95. package/plugins/design/dev-server/test/shared-doc-projection.test.ts +238 -0
  96. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  97. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  98. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  99. package/plugins/design/dev-server/test/sync-connection-state.test.ts +146 -0
  100. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  101. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  102. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  103. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  104. package/plugins/design/dev-server/test/sync-meta-codec.test.ts +123 -0
  105. package/plugins/design/dev-server/test/sync-runtime.test.ts +812 -0
  106. package/plugins/design/dev-server/test/sync-status.test.ts +80 -0
  107. package/plugins/design/dev-server/test/sync-untrusted.test.ts +104 -0
  108. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  109. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +38 -10
  110. package/plugins/design/dev-server/tool-palette.tsx +18 -16
  111. package/plugins/design/dev-server/undo-hud.tsx +4 -4
  112. package/plugins/design/dev-server/undo-stack.ts +20 -4
  113. package/plugins/design/dev-server/use-annotation-resize.tsx +16 -5
  114. package/plugins/design/dev-server/use-collab.tsx +157 -13
  115. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  116. package/plugins/design/dev-server/use-tool-mode.tsx +27 -12
  117. package/plugins/design/dev-server/ws.ts +40 -1
  118. package/plugins/design/templates/_shell.html +63 -5
  119. package/plugins/design/templates/canvas.tsx.template +13 -0
  120. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +15 -7
  121. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  122. package/plugins/flow/templates/ai-skeleton/README.md +1 -1
  123. package/plugins/flow/templates/ai-skeleton/gitignore +10 -0
  124. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +3 -1
  125. package/plugins/flow/templates/ai-skeleton/workflows.config.json +2 -1
@@ -0,0 +1,918 @@
1
+ // Sync runtime entry point — Phase 9 Task 4 wiring.
2
+ //
3
+ // Public surface for the dev-server boot:
4
+ //
5
+ // const runtime = createSyncRuntime(ctx);
6
+ // if (runtime) await runtime.start();
7
+ // // ... later ...
8
+ // await runtime.stop();
9
+ //
10
+ // Returns null when the project is unlinked (`.design/config.json` has no
11
+ // `linkedHub` field) — preserves solo mode behavior bit-for-bit. When linked:
12
+ // resolves the per-machine token, opens one HocuspocusProvider + sync agent
13
+ // per canvas, and wires the existing ctx.bus 'fs:any' events through the
14
+ // agent's echo guard.
15
+ //
16
+ // HocuspocusProvider import is dynamic so a misconfigured project (linked but
17
+ // the provider lib didn't install for some reason) prints a useful error
18
+ // instead of crashing the dev-server boot.
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { readdir } from 'node:fs/promises';
22
+ import path from 'node:path';
23
+
24
+ import type { Awareness } from 'y-protocols/awareness';
25
+ import * as Y from 'yjs';
26
+
27
+ import { Y_TYPES } from '../collab/persistence.ts';
28
+ import type { Context } from '../context.ts';
29
+ import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
30
+ import {
31
+ type ConnectionMonitor,
32
+ type ProviderStatus,
33
+ createConnectionMonitor,
34
+ } from './connection-state.ts';
35
+ import { type EchoGuard, createEchoGuard } from './echo-guard.ts';
36
+ import { type FsReader, createFsReader } from './fs-mirror.ts';
37
+ import { getHubToken } from './hubs-config.ts';
38
+ import { migrateSeed } from './migrate-seed.ts';
39
+ import { type DocProjection, createDocProjection } from './projection.ts';
40
+ import { type SyncStatusStore, createSyncStatusStore } from './status.ts';
41
+ import { writeUntrustedMarkers } from './untrusted.ts';
42
+
43
+ /** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
44
+ export interface SyncProvider {
45
+ readonly document: Y.Doc;
46
+ /**
47
+ * The provider's hub-synced Awareness, when it exposes one. Phase 9 Task 5
48
+ * bridges this to the collab Room's Awareness so cursors relay through the
49
+ * hub. Optional — a provider without awareness (or a test stub) just skips
50
+ * the bridge.
51
+ */
52
+ readonly awareness?: Awareness;
53
+ /** Resolves when the first hub sync handshake completes. */
54
+ onceSynced(): Promise<void>;
55
+ /**
56
+ * Subscribe to WS connection-status transitions (Phase 9 Task 8 offline
57
+ * mode). Returns an unsubscribe fn. Optional — a test stub or a provider
58
+ * without status events just isn't monitored (treated as always-online).
59
+ */
60
+ onStatus?(cb: (status: ProviderStatus) => void): () => void;
61
+ destroy(): void;
62
+ }
63
+
64
+ /**
65
+ * Structural surface of the collab registry the runtime needs for Task 5.
66
+ * Defined here (rather than imported from collab/) to avoid a dev-server
67
+ * module cycle — the real `Registry` satisfies it.
68
+ */
69
+ export interface AwarenessRegistry {
70
+ attachHubAwareness(slug: string, awareness: Awareness): () => void;
71
+ /** Phase 9.1 — relay a hub-pushed comment/annotation snapshot into a live
72
+ * room (wholesale, in-process) so the peer's canvas reflects it immediately
73
+ * and the room's own debounced persist can't clobber the synced state back.
74
+ * Optional so file-sync-only tests can pass a minimal registry. */
75
+ syncRoomFromComments?(slug: string, comments: readonly unknown[]): void;
76
+ syncRoomFromAnnotations?(slug: string, svg: string): void;
77
+ /**
78
+ * Phase 9.2 (DDR-064) — the single cached `Y.Doc` for a slug. When present
79
+ * AND `ctx.sharedDoc` is set, the runtime attaches the HocuspocusProvider to
80
+ * THIS doc instead of a fresh one (the convergence: one doc, both providers).
81
+ * Optional so file-sync-only tests can pass a minimal registry.
82
+ */
83
+ getDoc?(slug: string): Y.Doc;
84
+ /** Keep a shared-doc room alive while its provider is attached (drop guard). */
85
+ pin?(slug: string): void;
86
+ /** Release the shared-doc pin on runtime stop. */
87
+ unpin?(slug: string): void;
88
+ }
89
+
90
+ /** Factory the runtime calls per discovered canvas. Default uses Hocuspocus. */
91
+ export type ProviderFactory = (args: {
92
+ url: string;
93
+ token: string;
94
+ documentName: string;
95
+ /**
96
+ * Phase 9.2 (DDR-064) — when set, the provider MUST attach to this existing
97
+ * `Y.Doc` (the shared room doc) instead of creating its own. The runtime
98
+ * passes it only when `ctx.sharedDoc` is on and the registry exposes
99
+ * `getDoc`. Default factory: `args.document ?? new Y.Doc()`.
100
+ */
101
+ document?: Y.Doc;
102
+ }) => SyncProvider | Promise<SyncProvider>;
103
+
104
+ export interface SyncRuntime {
105
+ start(): Promise<void>;
106
+ stop(): Promise<void>;
107
+ /** Number of active per-canvas agents. */
108
+ size(): number;
109
+ /** Test inspection — get the agent for a slug if one was created. */
110
+ agentFor(slug: string): CanvasSyncAgent | undefined;
111
+ /** Current offline/sync status payload (Task 8), or null when unlinked. */
112
+ status(): import('./status.ts').SyncStatusPayload | null;
113
+ }
114
+
115
+ export interface CreateSyncRuntimeOptions {
116
+ /** Override the HocuspocusProvider factory (test injection). */
117
+ providerFactory?: ProviderFactory;
118
+ /** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
119
+ adopt?: boolean;
120
+ /** Discovery override — pass an explicit canvas list instead of scanning. */
121
+ canvases?: CanvasDescriptor[];
122
+ /**
123
+ * Collab registry — when provided, each provider's Awareness is bridged to
124
+ * the matching Room so cursors relay through the hub (Task 5). Omitted in
125
+ * unit tests that only exercise the file-sync path.
126
+ */
127
+ registry?: AwarenessRegistry;
128
+ /**
129
+ * Override the offline-mode connection monitor (Task 8 test injection —
130
+ * lets tests pass injectable timers/clock). Defaults to a real monitor that
131
+ * writes `_sync.json` + broadcasts 'sync:status' on the bus.
132
+ */
133
+ connectionMonitor?: ConnectionMonitor;
134
+ /** Override the status store (Task 8 test injection). */
135
+ statusStore?: SyncStatusStore;
136
+ }
137
+
138
+ /**
139
+ * One canvas the sync runtime tracks. `slug` is the stable doc-name shared
140
+ * with the hub; the three `paths` are absolute on-disk locations the agent
141
+ * mirrors.
142
+ */
143
+ export interface CanvasDescriptor {
144
+ slug: string;
145
+ html: string;
146
+ comments: string;
147
+ annotations: string;
148
+ /** The canvas `.meta.json` sidecar (sibling of the body). Phase 9.1 Gap 2 —
149
+ * shared keys (layout/artboards) sync; per-user viewport stays local. */
150
+ meta: string;
151
+ /** The canvas's sibling `.css` (Phase 9.1 Gap 3), synced as opaque text. */
152
+ css: string;
153
+ }
154
+
155
+ /**
156
+ * Build the sync runtime, or return null when the project isn't linked to a
157
+ * hub. Idempotent — callers can safely invoke this on every boot.
158
+ */
159
+ export function createSyncRuntime(
160
+ ctx: Context,
161
+ opts: CreateSyncRuntimeOptions = {}
162
+ ): SyncRuntime | null {
163
+ const linked = ctx.cfg.linkedHub;
164
+ if (!linked) return null;
165
+ const linkedHub = linked;
166
+
167
+ // DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
168
+ // where a future CI workflow runs `maude design serve` and a PR-controlled
169
+ // linkedHub.url silently grants a remote actor write access in an
170
+ // environment carrying GITHUB_TOKEN. Override via MAUDE_SYNC_IN_CI=1.
171
+ if (
172
+ !process.env.MAUDE_SYNC_IN_CI &&
173
+ (process.env.CI === 'true' || process.env.CI === '1' || !!process.env.GITHUB_ACTIONS)
174
+ ) {
175
+ console.warn(
176
+ '[sync] disabled in CI environment (CI / GITHUB_ACTIONS detected). DDR-054 §2a. Set MAUDE_SYNC_IN_CI=1 to override.'
177
+ );
178
+ return null;
179
+ }
180
+
181
+ // DDR-054 §2e — scheme allowlist. Refuse plaintext to non-loopback hosts
182
+ // (closes attacker F9 cleartext token exfil and the F2 last-mile chain).
183
+ const schemeError = checkUrlScheme(linkedHub.url);
184
+ if (schemeError) {
185
+ console.error(`[sync] refusing to start: ${schemeError}`);
186
+ return null;
187
+ }
188
+
189
+ const resolvedToken = getHubToken(linkedHub.url);
190
+ if (!resolvedToken) {
191
+ console.warn(
192
+ `[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
193
+ );
194
+ return null;
195
+ }
196
+ const token: string = resolvedToken;
197
+
198
+ const providerFactory = opts.providerFactory ?? defaultProviderFactory;
199
+ const echoGuard = createEchoGuard();
200
+ const agents = new Map<string, CanvasSyncAgent>();
201
+ // Phase 9.2 (DDR-064) — under sharedDoc the disk handler is a loop-free
202
+ // projection (sole owner of html/css/meta doc→file + all-types file→doc),
203
+ // created INSTEAD of an agent. Exactly one of agents/projections is populated
204
+ // per run (chosen by useSharedDoc).
205
+ const projections = new Map<string, DocProjection>();
206
+ const providers = new Map<string, SyncProvider>();
207
+ const awarenessDetaches: Array<() => void> = [];
208
+ const statusDetaches: Array<() => void> = [];
209
+ // Phase 9.2 (DDR-064) — slugs pinned in the registry because a provider is
210
+ // attached to their shared doc; released on stop(). Empty unless sharedDoc.
211
+ const pinnedSlugs = new Set<string>();
212
+ // The shared-doc convergence path is active only when the flag is ON AND the
213
+ // registry can hand us the canvas's single doc. Flag OFF / no registry / a
214
+ // minimal test registry without getDoc → the proven two-doc path, unchanged.
215
+ const useSharedDoc = !!ctx.sharedDoc && typeof opts.registry?.getDoc === 'function';
216
+ let fsReader: FsReader | null = null;
217
+ let busUnsub: (() => void) | null = null;
218
+ let started = false;
219
+ let stopped = false;
220
+
221
+ // Task 8 — offline-mode status surface, initialized in start() once the
222
+ // canvas count is known. The store writes `_sync.json` + broadcasts
223
+ // 'sync:status' on the bus; the monitor aggregates provider WS status into
224
+ // online/offline/escalated and feeds every change to the store.
225
+ let statusStore: SyncStatusStore | null = null;
226
+ let monitor: ConnectionMonitor | null = null;
227
+
228
+ async function start(): Promise<void> {
229
+ if (started || stopped) return;
230
+ started = true;
231
+
232
+ const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
233
+ const canvases = scan.canvases;
234
+ // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
235
+ // content, so the whole set is untrusted Claude-context. Mark it (writes
236
+ // `_untrusted/INDEX.json` + a managed `.claudeignore` block; clears both
237
+ // when the set is empty). Best-effort — never throws into boot.
238
+ writeUntrustedMarkers(ctx, canvases, linkedHub.url);
239
+ if (canvases.length === 0) {
240
+ // DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
241
+ // while syncing nothing (TSX-only projects: discovery admits .html only,
242
+ // .tsx needs the opt-in + sandbox gate that 9.1-A/B ship). Surface the gap
243
+ // loudly: a warn, a `_sync.json` the CLI + browser banner read, and a bus
244
+ // broadcast so open tabs render it immediately.
245
+ surfaceNoSyncable(ctx, linkedHub.url, scan.tsxCount);
246
+ return;
247
+ }
248
+
249
+ statusStore =
250
+ opts.statusStore ??
251
+ createSyncStatusStore({
252
+ url: linkedHub.url,
253
+ canvases: canvases.length,
254
+ sharedDoc: useSharedDoc,
255
+ write: (payload) => {
256
+ const file = path.join(ctx.paths.designRoot, '_sync.json');
257
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
258
+ },
259
+ broadcast: (payload) => ctx.bus.emit('sync:status', payload),
260
+ });
261
+ const store = statusStore;
262
+ monitor =
263
+ opts.connectionMonitor ?? createConnectionMonitor({ onChange: (snap) => store.update(snap) });
264
+ const mon = monitor;
265
+
266
+ const reader = createFsReader({
267
+ rootDir: ctx.paths.designRoot,
268
+ accept: (rel) => {
269
+ const ext = path.extname(rel).toLowerCase();
270
+ // `.tsx` added in T3 (9.1-B) so local edits to an opted-in syncable
271
+ // `.tsx` body propagate. Non-syncable `.tsx` changes still notify but
272
+ // match no agent in onRead (descriptor-scoped) → harmless no-op.
273
+ // `.css` added in Gap 3 so the canvas's sibling stylesheet syncs.
274
+ return (
275
+ ext === '.html' || ext === '.tsx' || ext === '.json' || ext === '.svg' || ext === '.css'
276
+ );
277
+ },
278
+ onRead: (evt) => {
279
+ const abs = path.join(ctx.paths.designRoot, evt.path);
280
+ // Dispatch to whichever disk handler owns this path. Only one of
281
+ // agents/projections is populated (useSharedDoc decides); the projector
282
+ // exposes the same applyFromFs(evt) shape as the agent.
283
+ for (const agent of agents.values()) {
284
+ if (agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
285
+ }
286
+ for (const proj of projections.values()) {
287
+ if (proj.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash })) return;
288
+ }
289
+ },
290
+ });
291
+ fsReader = reader;
292
+
293
+ busUnsub = ctx.bus.on('fs:any', (rel: string) => {
294
+ reader.notify(rel);
295
+ });
296
+
297
+ const adoptOnce = opts.adopt ?? !!linkedHub.adopt;
298
+ let adoptReconciled = 0;
299
+ const adoptTarget = canvases.length;
300
+
301
+ for (const canvas of canvases) {
302
+ try {
303
+ // Phase 9.2 (DDR-064) — when sharedDoc is on, the provider attaches to
304
+ // the collab room's single Y.Doc (registry.getDoc) instead of a fresh
305
+ // one, so browser edits flow straight into the doc that syncs to the
306
+ // hub — no disk hop, no relay, no clobber. Pin the room so the
307
+ // last-browser-leaves drop can't destroy the doc out from under the
308
+ // provider. NB: cold-start seeding of a divergent local+hub doc is the
309
+ // duplication trap (Risk 1) — made safe by Phase E (migrate-seed); the
310
+ // flag stays OFF until then.
311
+ const sharedYDoc = useSharedDoc ? opts.registry?.getDoc?.(canvas.slug) : undefined;
312
+ if (useSharedDoc && sharedYDoc) {
313
+ opts.registry?.pin?.(canvas.slug);
314
+ pinnedSlugs.add(canvas.slug);
315
+ }
316
+ const provider = await providerFactory({
317
+ url: linkedHub.url,
318
+ token,
319
+ documentName: canvas.slug,
320
+ document: sharedYDoc,
321
+ });
322
+ providers.set(canvas.slug, provider);
323
+ const canvasPaths = {
324
+ html: canvas.html,
325
+ comments: canvas.comments,
326
+ annotations: canvas.annotations,
327
+ meta: canvas.meta,
328
+ css: canvas.css,
329
+ };
330
+ // Phase 9.2 (DDR-064) — the disk handler. Under sharedDoc it's a
331
+ // loop-free projection (html/css/meta doc→file + all-types file→doc;
332
+ // the collab room keeps comments/annotations doc→file, so no
333
+ // double-write). Flag-OFF keeps the proven two-doc agent.
334
+ let agent: CanvasSyncAgent | undefined;
335
+ let projection: DocProjection | undefined;
336
+ if (useSharedDoc && sharedYDoc) {
337
+ projection = createDocProjection({
338
+ slug: canvas.slug,
339
+ doc: provider.document,
340
+ paths: canvasPaths,
341
+ echoGuard,
342
+ });
343
+ projection.start();
344
+ projections.set(canvas.slug, projection);
345
+ } else {
346
+ agent = createCanvasSyncAgent({
347
+ slug: canvas.slug,
348
+ doc: provider.document,
349
+ paths: canvasPaths,
350
+ echoGuard,
351
+ adopt: adoptOnce,
352
+ onConflict: (info) => store.addConflict(info),
353
+ });
354
+ agent.start();
355
+ agents.set(canvas.slug, agent);
356
+ }
357
+
358
+ // Task 8 — feed this provider's WS status into the offline monitor.
359
+ if (provider.onStatus) {
360
+ statusDetaches.push(provider.onStatus((s) => mon.noteProviderStatus(canvas.slug, s)));
361
+ } else {
362
+ // No status events (test stub) — treat as connected so the monitor
363
+ // doesn't sit in the boot 'connecting' state forever.
364
+ mon.noteProviderStatus(canvas.slug, 'connected');
365
+ }
366
+ // Count local edits (agent-origin doc updates) toward queuedOps while
367
+ // the hub is unreachable — the banner's "N edits queued" figure. Under
368
+ // sharedDoc there is no agent origin to key off (browser edits carry a
369
+ // RoomConn origin); queued-edit counting in that mode is a known gap
370
+ // (offline-banner accuracy only, not data) deferred past Phase C.
371
+ if (agent) {
372
+ const agentOrigin = agent.origin;
373
+ const onLocalUpdate = (_u: Uint8Array, origin: unknown) => {
374
+ if (origin === agentOrigin) mon.noteLocalEdit();
375
+ };
376
+ provider.document.on('update', onLocalUpdate);
377
+ statusDetaches.push(() => provider.document.off('update', onLocalUpdate));
378
+ }
379
+
380
+ // Task 5 — bridge the provider's hub-synced Awareness to the Room so
381
+ // browser cursors relay cross-machine. No-op when the provider exposes
382
+ // no awareness or no registry was passed (file-sync-only tests).
383
+ if (opts.registry && provider.awareness) {
384
+ awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
385
+ }
386
+
387
+ // Relay hub-pushed comment/annotation changes straight into the live
388
+ // room — IN-PROCESS + synchronous, so the room's in-memory doc is
389
+ // updated BEFORE its 800ms persist timer can flush stale pre-sync state
390
+ // back over the file (the disk-mediated re-seed in createCollab loses
391
+ // that race under an actively-edited peer; this is the tight path that
392
+ // actually closes the "comment reverts" clobber). Wholesale-replace via
393
+ // syncRoomFrom* → no duplication. Skip agent-origin updates (our own
394
+ // disk→doc apply — the file is authoritative there; a local design:edit
395
+ // reaches the room via createCollab's fs hook instead).
396
+ //
397
+ // CRITICAL: observe the comment + annotation Y-types SEPARATELY, not the
398
+ // whole-doc update. A whole-doc relay re-applies BOTH types on every
399
+ // change, so a comment sync would re-push the (stale) annotation and
400
+ // clobber an annotation the peer just drew but hasn't synced yet — and
401
+ // vice versa. Per-type observers keep the two lanes independent.
402
+ //
403
+ // Phase 9.2 (DDR-064): under sharedDoc the provider IS attached to the
404
+ // room's doc, so there is no second doc to relay into — the room already
405
+ // has every change. Skipping the relay is what RETIRES the
406
+ // wholesale-replace clobber path (the Phase 9.1 ceiling): with one doc,
407
+ // CRDT merge handles concurrency, no last-writer-wins blob copy.
408
+ const reg = opts.registry;
409
+ if (!useSharedDoc && agent && reg?.syncRoomFromComments) {
410
+ const agentOrigin = agent.origin;
411
+ const slug = canvas.slug;
412
+ const provComments = provider.document.getArray(Y_TYPES.comments);
413
+ const provAnn = provider.document.getMap(Y_TYPES.annotations);
414
+ const onComments = (_e: unknown, tx: { origin: unknown }) => {
415
+ if (tx.origin === agentOrigin) return;
416
+ reg.syncRoomFromComments?.(slug, provComments.toArray());
417
+ };
418
+ const onAnn = (_e: unknown, tx: { origin: unknown }) => {
419
+ if (tx.origin === agentOrigin) return;
420
+ const svg = provAnn.get('svg');
421
+ if (typeof svg === 'string') reg.syncRoomFromAnnotations?.(slug, svg);
422
+ };
423
+ provComments.observe(onComments);
424
+ provAnn.observe(onAnn);
425
+ statusDetaches.push(() => {
426
+ provComments.unobserve(onComments);
427
+ provAnn.unobserve(onAnn);
428
+ });
429
+ }
430
+
431
+ // Cold-start reconcile fires once the provider has hub state.
432
+ void provider.onceSynced().then(async () => {
433
+ if (projection) {
434
+ // Phase E (DDR-064 Task 9) — one-time authoritative seed BEFORE
435
+ // materializing: escapes the duplication trap by picking ONE source
436
+ // (hub-wins if the synced doc holds state; adopt local files only
437
+ // when the hub was empty), inside a MIGRATION transaction. The room
438
+ // file-seed is disabled for this pinned slug (createCollab
439
+ // shouldSeed), so no duplicate items can be introduced afterward.
440
+ const result = migrateSeed({
441
+ slug: canvas.slug,
442
+ doc: provider.document,
443
+ paths: canvasPaths,
444
+ historyDir: path.join(ctx.paths.historyDir, canvas.slug),
445
+ });
446
+ if (result === 'local-adopt') {
447
+ console.log(`[sync/${canvas.slug}] shared-doc: adopted local state (hub was empty).`);
448
+ }
449
+ // Then materialize the converged doc to disk (safe — never clobbers
450
+ // non-empty local with an empty doc value).
451
+ projection.reconcile();
452
+ return;
453
+ }
454
+ if (!agent) return;
455
+ await agent.reconcile();
456
+ if (adoptOnce) {
457
+ adoptReconciled++;
458
+ if (adoptReconciled === adoptTarget) {
459
+ // All canvases adopted — clear the flag from .design/config.json
460
+ // so re-running serve doesn't re-trigger. DDR-054 §2i.
461
+ clearAdoptFlag(ctx);
462
+ }
463
+ }
464
+ });
465
+ } catch (err) {
466
+ console.error(`[sync/${canvas.slug}] failed to start:`, err);
467
+ }
468
+ }
469
+
470
+ // Persist an initial status snapshot so `_sync.json` exists — and `maude
471
+ // design status` + the browser banner report "agent running" — from the
472
+ // moment serve boots. The ConnectionMonitor only emits on *transitions* and
473
+ // starts in 'online', so on a clean fast localhost connect (provider
474
+ // reaches 'connected' at/before subscribe → no transition fires) nothing
475
+ // would otherwise be written, and status would read "idle / sync agent not
476
+ // running" while sync is in fact healthy. This was the observed bug.
477
+ store.update(mon.snapshot());
478
+
479
+ console.log(
480
+ `[sync] linked to ${linkedHub.url} — ${agents.size + projections.size}/${canvases.length} canvas(es) syncing${useSharedDoc ? ' (shared-doc)' : ''}${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
481
+ );
482
+ }
483
+
484
+ async function stop(): Promise<void> {
485
+ if (stopped) return;
486
+ stopped = true;
487
+ for (const detach of awarenessDetaches) {
488
+ try {
489
+ detach();
490
+ } catch {
491
+ /* best-effort — registry teardown also clears bridges on destroyAll */
492
+ }
493
+ }
494
+ awarenessDetaches.length = 0;
495
+ // Phase 9.2 (DDR-064) — release shared-doc pins so the rooms can be dropped
496
+ // / destroyed normally on shutdown. Empty unless sharedDoc was active.
497
+ for (const slug of pinnedSlugs) {
498
+ try {
499
+ opts.registry?.unpin?.(slug);
500
+ } catch {
501
+ /* best-effort */
502
+ }
503
+ }
504
+ pinnedSlugs.clear();
505
+ for (const detach of statusDetaches) {
506
+ try {
507
+ detach();
508
+ } catch {
509
+ /* best-effort */
510
+ }
511
+ }
512
+ statusDetaches.length = 0;
513
+ monitor?.stop();
514
+ busUnsub?.();
515
+ busUnsub = null;
516
+ fsReader?.stop();
517
+ fsReader = null;
518
+ for (const agent of agents.values()) {
519
+ try {
520
+ await agent.flush();
521
+ agent.stop();
522
+ } catch {
523
+ /* best-effort */
524
+ }
525
+ }
526
+ agents.clear();
527
+ // Phase 9.2 (DDR-064) — final doc→file flush + stop the projectors BEFORE
528
+ // tearing down providers (so the converged doc lands on disk). The shared
529
+ // doc itself is owned by the collab room and destroyed by registry teardown,
530
+ // not here.
531
+ for (const proj of projections.values()) {
532
+ try {
533
+ await proj.flush();
534
+ proj.stop();
535
+ } catch {
536
+ /* best-effort */
537
+ }
538
+ }
539
+ projections.clear();
540
+ for (const provider of providers.values()) {
541
+ try {
542
+ provider.destroy();
543
+ } catch {
544
+ /* best-effort */
545
+ }
546
+ }
547
+ providers.clear();
548
+ }
549
+
550
+ return {
551
+ start,
552
+ stop,
553
+ // Under sharedDoc the per-canvas handler is a projection, not an agent;
554
+ // count both so size() reflects the synced-canvas count in either mode.
555
+ size: () => agents.size + projections.size,
556
+ agentFor: (slug) => agents.get(slug),
557
+ status: () => statusStore?.get() ?? null,
558
+ };
559
+ }
560
+
561
+ /* ---------------------------------------------------------------- discovery */
562
+
563
+ /**
564
+ * Scan `<designRoot>/{ui,system}/` for `.html` canvas files and return one
565
+ * CanvasDescriptor per. Mirrors the existing api.ts file-tree scan but
566
+ * specialised for the sync runtime (we only need the three paths per canvas,
567
+ * not the full metadata).
568
+ *
569
+ * DDR-054 §2b / DDR-060 — `.tsx` canvases are deliberately EXCLUDED from sync.
570
+ * The dev-server transpiles `.tsx` to JavaScript and serves it as
571
+ * `application/javascript` in iframe same-origin; a hostile hub pushing
572
+ * arbitrary TypeScript source would result in RCE (the audit's CRITICAL F1).
573
+ * Since Phase 3.6 made `.tsx` the ONLY canvas format, this means real projects
574
+ * discover zero syncable canvases — surfaced loudly by `surfaceNoSyncable`
575
+ * (9.1-D) rather than silently. The per-canvas opt-in (`.meta.json.syncable:
576
+ * true`) + CSP/sandbox gate that make `.tsx` syncable land in 9.1-A/B.
577
+ */
578
+ export async function discoverCanvases(ctx: Context): Promise<CanvasDescriptor[]> {
579
+ return (await scanCanvases(ctx)).canvases;
580
+ }
581
+
582
+ /** Result of a canvas-group scan: syncable descriptors + a tally of the .tsx
583
+ * canvases that exist but are NOT yet syncable (DDR-060 — they need the
584
+ * per-canvas opt-in + sandbox gate). The tsx count feeds 9.1-D's loud
585
+ * zero-syncable surface so the message can say *why* nothing syncs. */
586
+ export interface CanvasScan {
587
+ canvases: CanvasDescriptor[];
588
+ tsxCount: number;
589
+ }
590
+
591
+ export async function scanCanvases(ctx: Context): Promise<CanvasScan> {
592
+ const out: CanvasDescriptor[] = [];
593
+ const counter = { tsx: 0 };
594
+ // T3 (9.1-B) — LOAD-BEARING coupling (the plan's "two locks flip together"
595
+ // invariant): a `.tsx` body is admitted to sync ONLY when the cross-origin
596
+ // CSP/sandbox containment is active (`ctx.canvasOrigin` is set — Lock 2). The
597
+ // sandbox is now ON BY DEFAULT, but a user can opt out with
598
+ // MAUDE_CANVAS_ORIGIN_SPLIT=0; if they do, `canvasOrigin` is undefined and NO
599
+ // `.tsx` syncs — the per-canvas opt-in is inert without the sandbox, and
600
+ // decoupling them would re-open the CRITICAL F1 RCE (DDR-060, DDR-054 §F1).
601
+ const splitActive = !!ctx.canvasOrigin;
602
+ for (const group of ctx.cfg.canvasGroups) {
603
+ const groupAbs = path.join(ctx.paths.designRoot, group.path);
604
+ if (!existsSync(groupAbs)) continue;
605
+ await walk(
606
+ groupAbs,
607
+ ctx.paths.designRoot,
608
+ ctx.paths.commentsDir,
609
+ ctx.paths.designRel,
610
+ out,
611
+ counter,
612
+ splitActive
613
+ );
614
+ }
615
+ return { canvases: out, tsxCount: counter.tsx };
616
+ }
617
+
618
+ /**
619
+ * T3 (9.1-B) — per-canvas sync opt-in. A `.tsx` canvas is syncable only if its
620
+ * sibling `<name>.meta.json` declares `"syncable": true`. The flag is set by a
621
+ * human editing the sidecar; it is deliberately NOT in the untrusted
622
+ * `/_api/canvas-meta` PATCH whitelist (api.ts), so a hostile canvas/hub cannot
623
+ * flip its own body into the sync set. Missing sidecar / parse error → false.
624
+ */
625
+ function readSyncableFlag(bodyAbs: string): boolean {
626
+ const metaAbs = bodyAbs.replace(/\.(tsx|html)$/i, '.meta.json');
627
+ try {
628
+ const obj = JSON.parse(readFileSync(metaAbs, 'utf8'));
629
+ return obj && typeof obj === 'object' && obj.syncable === true;
630
+ } catch {
631
+ return false;
632
+ }
633
+ }
634
+
635
+ async function walk(
636
+ dirAbs: string,
637
+ designRoot: string,
638
+ commentsDir: string,
639
+ designRel: string,
640
+ acc: CanvasDescriptor[],
641
+ counter: { tsx: number },
642
+ splitActive: boolean
643
+ ): Promise<void> {
644
+ let entries: import('node:fs').Dirent[];
645
+ try {
646
+ entries = await readdir(dirAbs, { withFileTypes: true });
647
+ } catch {
648
+ return;
649
+ }
650
+ for (const entry of entries) {
651
+ const abs = path.join(dirAbs, entry.name);
652
+ if (entry.isDirectory()) {
653
+ // Skip plugin runtime dirs.
654
+ if (entry.name.startsWith('_')) continue;
655
+ await walk(abs, designRoot, commentsDir, designRel, acc, counter, splitActive);
656
+ continue;
657
+ }
658
+ const ext = path.extname(entry.name).toLowerCase();
659
+ // T3 (9.1-B) — a `.tsx` syncs ONLY when the sandbox is active AND the
660
+ // sidecar opts in (see readSyncableFlag + the splitActive coupling above).
661
+ // Otherwise tally it for 9.1-D's loud zero-syncable surface so the message
662
+ // can explain *why* it isn't syncing.
663
+ if (ext === '.tsx') {
664
+ if (!(splitActive && readSyncableFlag(abs))) {
665
+ counter.tsx += 1;
666
+ continue;
667
+ }
668
+ // falls through to descriptor push (body = the .tsx file)
669
+ } else if (ext !== '.html') {
670
+ continue;
671
+ }
672
+ const slug = slugFor(abs, designRoot, designRel);
673
+ acc.push({
674
+ // `html` is the canvas BODY path — `.html` or an opted-in `.tsx`. The
675
+ // sync codec treats the body as opaque Y.Text, so the field name is
676
+ // historical; both formats round-trip identically (DDR-060).
677
+ slug,
678
+ html: abs,
679
+ comments: path.join(commentsDir, `${slug}.json`),
680
+ annotations: path.join(designRoot, `${slug}.annotations.svg`),
681
+ // The `.meta.json` sidecar sits next to the body: `Foo.tsx` → `Foo.meta.json`.
682
+ meta: abs.replace(/\.(tsx|html)$/i, '.meta.json'),
683
+ // The `.css` sibling: `Foo.tsx` → `Foo.css` (absent for inline-CSS canvases).
684
+ css: abs.replace(/\.(tsx|html)$/i, '.css'),
685
+ });
686
+ }
687
+ }
688
+
689
+ /**
690
+ * The shape written to `<designRoot>/_sync.json` (and broadcast on the
691
+ * 'sync:status' bus) when the project is linked but has zero syncable
692
+ * canvases. DDR-060 / 9.1-D. Distinct from the live SyncStatusPayload — the
693
+ * `notSyncable` discriminator lets the CLI status line and the browser banner
694
+ * render the "linked but nothing syncs" state instead of a healthy one.
695
+ */
696
+ export interface NoSyncablePayload {
697
+ linked: true;
698
+ notSyncable: true;
699
+ url: string;
700
+ reason: string;
701
+ /** Count of .tsx canvases present (syncable once 9.1-A/B land). */
702
+ tsxCount: number;
703
+ canvases: 0;
704
+ updatedAt: number;
705
+ }
706
+
707
+ /** Build the zero-syncable payload (exported for the CLI + tests). */
708
+ export function buildNoSyncablePayload(
709
+ url: string,
710
+ tsxCount: number,
711
+ designRoot: string
712
+ ): NoSyncablePayload {
713
+ const reason =
714
+ tsxCount > 0
715
+ ? `${tsxCount} TSX canvas(es) found but none are syncable — a TSX body syncs only when the canvas opts in (.meta.json "syncable": true). The canvas sandbox is on by default; MAUDE_CANVAS_ORIGIN_SPLIT=0 disables it (and TSX sync with it). See DDR-060.`
716
+ : `no canvases found under ${designRoot}.`;
717
+ return {
718
+ linked: true,
719
+ notSyncable: true,
720
+ url,
721
+ reason,
722
+ tsxCount,
723
+ canvases: 0,
724
+ updatedAt: Date.now(),
725
+ };
726
+ }
727
+
728
+ /**
729
+ * 9.1-D — replace the silent zero-canvas early-return with a loud surface:
730
+ * a warn line, a `_sync.json` the CLI + browser read, and a bus broadcast.
731
+ * Best-effort on the write/broadcast — a failure there must never throw into
732
+ * the boot path (solo mode for unlinked projects is unaffected either way).
733
+ */
734
+ function surfaceNoSyncable(ctx: Context, url: string, tsxCount: number): void {
735
+ const payload = buildNoSyncablePayload(url, tsxCount, ctx.paths.designRoot);
736
+ console.warn(`[sync] linked to ${url} but 0 syncable canvases — ${payload.reason}`);
737
+ try {
738
+ const file = path.join(ctx.paths.designRoot, '_sync.json');
739
+ writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
740
+ } catch {
741
+ /* best-effort — never throw into boot */
742
+ }
743
+ try {
744
+ ctx.bus.emit('sync:status', payload);
745
+ } catch {
746
+ /* best-effort */
747
+ }
748
+ }
749
+
750
+ function slugFor(absPath: string, designRoot: string, designRel: string): string {
751
+ let rel = path.relative(designRoot, absPath);
752
+ rel = rel.replace(/\\/g, '/');
753
+ // Mirror api.fileSlug(): collapse path separators to '-', strip extension.
754
+ const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
755
+ if (rel.startsWith(prefix)) rel = rel.slice(prefix.length);
756
+ return rel
757
+ .replace(/\//g, '-')
758
+ .replace(/\s+/g, '_')
759
+ .replace(/\.(tsx|html)$/i, '')
760
+ .replace(/^\.+/, '')
761
+ .toLowerCase();
762
+ }
763
+
764
+ /* ---------------------------------------------------------------- default provider */
765
+
766
+ /**
767
+ * The production provider factory — instantiates a real HocuspocusProvider.
768
+ * Imported dynamically so tests / unlinked projects don't pay the load cost.
769
+ */
770
+ async function defaultProviderFactory(args: {
771
+ url: string;
772
+ token: string;
773
+ documentName: string;
774
+ document?: Y.Doc;
775
+ }): Promise<SyncProvider> {
776
+ // biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
777
+ let mod: any;
778
+ try {
779
+ mod = await import('@hocuspocus/provider');
780
+ } catch (err) {
781
+ throw new Error(
782
+ `@hocuspocus/provider unavailable — install it under plugins/design/dev-server/. (${err instanceof Error ? err.message : String(err)})`
783
+ );
784
+ }
785
+ // Hocuspocus accepts ws:// or wss://; the linked URL is http(s)://, so swap
786
+ // the scheme. The provider also accepts http(s):// and upgrades internally
787
+ // in newer versions, but ws:// is explicit + portable.
788
+ const wsUrl = toWsUrl(args.url);
789
+ // Phase 9.2 (DDR-064) — attach to the shared room doc when the runtime
790
+ // injected one; otherwise own a fresh doc (the legacy two-doc path).
791
+ const document = args.document ?? new Y.Doc();
792
+ // biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
793
+ const provider: any = new mod.HocuspocusProvider({
794
+ url: wsUrl,
795
+ name: args.documentName,
796
+ token: args.token,
797
+ document,
798
+ connect: true,
799
+ // Make a rejected handshake LOUD instead of an invisible reconnect loop.
800
+ // The most common cause is a scope-bound token (DDR-053 default scope =
801
+ // label) that can't authorize this canvas's flat-slug documentName — that
802
+ // failure mode previously looked identical to "hub unreachable / offline".
803
+ onAuthenticationFailed: (data: { reason?: string }) => {
804
+ const reason = (data?.reason ?? 'unknown reason').replace(/[\r\n]/g, ' ').slice(0, 200);
805
+ console.warn(
806
+ `[sync] hub REJECTED auth for documentName="${args.documentName}": ${reason}.
807
+ If this is "token not authorized for this documentName", the token's scope
808
+ does not cover this canvas — mint a hub-wide token (\`maude hub token generate
809
+ --scope '*'\` or an admin-UI invite) and re-link. The peer keeps retrying until then.`
810
+ );
811
+ },
812
+ });
813
+ return {
814
+ document,
815
+ // HocuspocusProvider creates a hub-synced Awareness by default; expose it
816
+ // so the runtime can bridge it to the collab Room (Task 5).
817
+ awareness: provider.awareness as Awareness | undefined,
818
+ onStatus(cb: (status: ProviderStatus) => void): () => void {
819
+ // HocuspocusProvider emits 'status' with { status: 'connected' |
820
+ // 'connecting' | 'disconnected' } on every WS transition (Task 8).
821
+ const handler = (evt: { status?: string }) => {
822
+ const s = evt?.status;
823
+ if (s === 'connected' || s === 'connecting' || s === 'disconnected') cb(s);
824
+ };
825
+ provider.on('status', handler);
826
+ // Seed the subscriber with the provider's CURRENT status immediately. On
827
+ // localhost the WS can reach 'connected'/synced inside the constructor —
828
+ // before this listener attaches — so waiting for the *next* transition
829
+ // would leave the connection monitor un-seeded and `_sync.json` never
830
+ // written (status reads "idle / sync agent not running" while sync is in
831
+ // fact healthy). Reading provider.status closes that race; it always
832
+ // reflects the true current state, so a missed event can't strand us.
833
+ const cur = provider.status;
834
+ if (cur === 'connected' || cur === 'connecting' || cur === 'disconnected') cb(cur);
835
+ return () => provider.off('status', handler);
836
+ },
837
+ onceSynced(): Promise<void> {
838
+ return new Promise<void>((resolve) => {
839
+ if (provider.synced) {
840
+ resolve();
841
+ return;
842
+ }
843
+ const handler = () => {
844
+ provider.off('synced', handler);
845
+ resolve();
846
+ };
847
+ provider.on('synced', handler);
848
+ });
849
+ },
850
+ destroy() {
851
+ provider.destroy();
852
+ },
853
+ };
854
+ }
855
+
856
+ /** Convert an http(s):// URL to ws(s):// for the HocuspocusProvider. */
857
+ export function toWsUrl(httpUrl: string): string {
858
+ if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
859
+ if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
860
+ return httpUrl;
861
+ }
862
+
863
+ /**
864
+ * Rewrite .design/config.json to drop `linkedHub.adopt`. Called once after
865
+ * all canvases finish their first adopt-reconcile. DDR-054 §2i (defender I5).
866
+ * Best-effort — failure logs and leaves the disk flag in place; the only
867
+ * downstream cost is the user being prompted to re-run adopt.
868
+ */
869
+ function clearAdoptFlag(ctx: Context): void {
870
+ const cfgPath = path.join(ctx.paths.repoRoot, '.design', 'config.json');
871
+ if (!existsSync(cfgPath)) return;
872
+ try {
873
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8')) as {
874
+ linkedHub?: { adopt?: boolean; lastAdoptedAt?: number };
875
+ };
876
+ if (!raw?.linkedHub?.adopt) return;
877
+ raw.linkedHub.adopt = undefined;
878
+ raw.linkedHub.lastAdoptedAt = Date.now();
879
+ // Strip undefined values via stringify/parse so the JSON output is clean.
880
+ const cleaned = JSON.parse(JSON.stringify(raw));
881
+ writeFileSync(cfgPath, `${JSON.stringify(cleaned, null, 2)}\n`, 'utf8');
882
+ console.log('[sync] adopt complete — cleared linkedHub.adopt from .design/config.json');
883
+ } catch (err) {
884
+ console.warn(
885
+ '[sync] failed to clear linkedHub.adopt:',
886
+ err instanceof Error ? err.message : err
887
+ );
888
+ }
889
+ }
890
+
891
+ /**
892
+ * Refuse non-loopback ws:// / http:// (cleartext token exposure to MITM).
893
+ * DDR-054 §2e (attacker F9). Returns null on accept, error string on refuse.
894
+ *
895
+ * Loopback hosts (localhost, 127.0.0.1, [::1], ::1) keep ws:// allowed for
896
+ * local hub development. Non-http(s)/ws(s) schemes are refused outright.
897
+ */
898
+ export function checkUrlScheme(url: string): string | null {
899
+ let u: URL;
900
+ try {
901
+ u = new URL(url);
902
+ } catch {
903
+ return `invalid hub URL: ${url}`;
904
+ }
905
+ const proto = u.protocol.toLowerCase();
906
+ if (proto !== 'http:' && proto !== 'https:' && proto !== 'ws:' && proto !== 'wss:') {
907
+ return `unsupported hub URL scheme: ${proto} (expected https:// or wss://)`;
908
+ }
909
+ const isPlaintext = proto === 'http:' || proto === 'ws:';
910
+ if (!isPlaintext) return null;
911
+ const host = u.hostname.toLowerCase();
912
+ const isLoopback =
913
+ host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
914
+ if (!isLoopback) {
915
+ return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${host} or change the host to localhost.`;
916
+ }
917
+ return null;
918
+ }