@1agh/maude 0.30.0 → 0.31.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 (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -82,6 +82,18 @@ function isDevServerDir(dir: string): boolean {
82
82
  }
83
83
 
84
84
  function resolveDevServerRoot(): string {
85
+ // (0) Explicit override. Used when the runtime can't be found by walk-up — e.g.
86
+ // the Tauri desktop bundle (DDR-106) ships apps/studio/ as an app resource and
87
+ // sets MAUDE_DEV_SERVER_ROOT to it, because the sidecar binary sits alone in
88
+ // Maude.app/Contents/MacOS/ with no apps/studio/ up-tree (the bundle ships the
89
+ // full source, so `http.ts` is present at the resource root).
90
+ const override = process.env.MAUDE_DEV_SERVER_ROOT;
91
+ // Require the same `http.ts` anchor walk-up uses (not merely a `dist/` dir) so a
92
+ // planted dist-only directory can't hijack the runtime root (security review F4).
93
+ if (override && !isVirtualBunfsPath(override) && isDevServerDir(override)) {
94
+ return override;
95
+ }
96
+
85
97
  // (1) Dev mode: import.meta.url is a real file:// path AND lands in the
86
98
  // dev-server dir. Common case for `bun run server.ts`, tests, etc.
87
99
  const importDir = getImportMetaDir();
@@ -0,0 +1,57 @@
1
+ // scaffold-design.ts — write a minimal, BOOTABLE `.design/` into a folder (Phase 28).
2
+ //
3
+ // Used by two native-app flows: "New project" (create a GitHub repo → init a local
4
+ // project) and the "open a repo with no Maude design system" fallback ("set it up?").
5
+ // It deliberately scaffolds only the MINIMUM the dev-server needs to boot (per
6
+ // context.ts the only hard requirement is a `.design/` dir; config needs just
7
+ // `name` + `designRoot`). A real design system is still created by /design:setup-ds
8
+ // (agent-driven) — this just gets the project to open instead of crash-looping.
9
+
10
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
11
+ import { basename, join } from 'node:path';
12
+
13
+ const CONFIG_SCHEMA =
14
+ 'https://raw.githubusercontent.com/1aGh/maude/main/apps/studio/config.schema.json';
15
+
16
+ /** Whether `dir` is already a Maude project (has `.design/config.json`). */
17
+ export function hasDesign(dir: string): boolean {
18
+ return existsSync(join(dir, '.design', 'config.json'));
19
+ }
20
+
21
+ export interface ScaffoldResult {
22
+ ok: boolean;
23
+ error?: string;
24
+ }
25
+
26
+ /** Scaffold a minimal bootable `.design/` (no design system yet). Refuses to
27
+ * clobber an existing project. `name` is the human project label. */
28
+ export function scaffoldDesign(dir: string, name?: string): ScaffoldResult {
29
+ const designDir = join(dir, '.design');
30
+ const configPath = join(designDir, 'config.json');
31
+ if (existsSync(configPath)) {
32
+ return { ok: false, error: 'This folder is already a Maude project.' };
33
+ }
34
+ const projectName = (name && name.trim()) || basename(dir) || 'Untitled';
35
+ try {
36
+ mkdirSync(join(designDir, 'ui'), { recursive: true });
37
+ mkdirSync(join(designDir, 'system'), { recursive: true });
38
+ const config = {
39
+ $schema: CONFIG_SCHEMA,
40
+ name: projectName,
41
+ designRoot: '.design',
42
+ canvasGroups: [
43
+ { label: 'Design system', path: 'system' },
44
+ { label: 'UI kit', path: 'ui' },
45
+ ],
46
+ designSystems: [],
47
+ completenessProfile: 'standard',
48
+ };
49
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8');
50
+ // Keep the empty group dirs in git so a fresh clone still has the structure.
51
+ writeFileSync(join(designDir, 'ui', '.gitkeep'), '', 'utf8');
52
+ writeFileSync(join(designDir, 'system', '.gitkeep'), '', 'utf8');
53
+ return { ok: true };
54
+ } catch (e) {
55
+ return { ok: false, error: e instanceof Error ? e.message : 'Could not set up the project.' };
56
+ }
57
+ }
@@ -16,14 +16,17 @@
16
16
 
17
17
  import { spawn } from 'node:child_process';
18
18
 
19
+ import { createAcp } from './acp/index.ts';
19
20
  import { createActivity } from './activity.ts';
20
21
  import { createApi } from './api.ts';
21
22
  import { bootSelfHeal } from './boot-self-heal.ts';
22
- import { createAiActivity } from './collab/ai-activity.ts';
23
+ import { createCanvasListWatch } from './canvas-list-watch.ts';
24
+ import { type AiActivityEntry, createAiActivity } from './collab/ai-activity.ts';
23
25
  import { createGitLifecycle } from './collab/git-lifecycle.ts';
24
26
  import { createCollab } from './collab/index.ts';
25
27
  import { createContext } from './context.ts';
26
28
  import { createFsWatch } from './fs-watch.ts';
29
+ import { createGitWatch } from './git/watch.ts';
27
30
  import { createHttp } from './http.ts';
28
31
  import { createInspect } from './inspect.ts';
29
32
  import { startHeapWatch } from './mem.ts';
@@ -77,11 +80,28 @@ await inspect.load();
77
80
 
78
81
  collab = createCollab(ctx, api);
79
82
  const aiActivity = createAiActivity(ctx);
83
+
84
+ // Phase 30 — bridge agent `ai-activity` onto the per-canvas room awareness so a
85
+ // remote peer sees "X is editing" cross-machine. The `ai-activity` bus event is
86
+ // loopback-only (inspector WS); awareness is the one channel that crosses the
87
+ // hub. Soft heads-up — projected onto the room's own awareness slot, cleared
88
+ // when the activity ends/expires. No-op when no room is live for the slug.
89
+ ctx.bus.on('ai-activity', (payload: { file: string; entry: AiActivityEntry | null }) => {
90
+ if (!collab) return;
91
+ const slug = api.fileSlug(payload.file);
92
+ collab.registry.setAgentEditing(
93
+ slug,
94
+ payload.entry ? { name: payload.entry.author, since: payload.entry.startedAt } : null
95
+ );
96
+ });
80
97
  const gitLifecycle = createGitLifecycle(ctx, collab.registry);
81
98
  // Phase 13 / DDR-029 — fs-watch-driven canvas activity overlay. Subscribes to
82
99
  // `fs:any` and emits `activity:change`; ws.ts forwards it to canvas iframes.
83
100
  const activity = createActivity(ctx);
84
- const ws = createWs(ctx, api, inspect, collab, activity);
101
+ // Phase 31 (DDR-123) ACP chat bridge manager. Owns one claude-agent-acp
102
+ // subprocess per /_ws/acp socket; main-origin + loopback only (wired below).
103
+ const acp = createAcp(ctx);
104
+ const ws = createWs(ctx, api, inspect, collab, activity, acp);
85
105
  const http = createHttp(ctx, api, inspect, aiActivity);
86
106
  const fsWatch = createFsWatch(ctx);
87
107
 
@@ -141,6 +161,26 @@ function startServer(port: number): BunServer {
141
161
  return new Response('Upgrade failed', { status: 400 });
142
162
  }
143
163
 
164
+ // Phase 31 (DDR-123) — ACP chat bridge WS. Main origin ONLY (this server,
165
+ // never startCanvasServer) and loopback-guarded like collab: the bridge
166
+ // spawns the user's `claude` and can drive file edits, so the untrusted
167
+ // canvas origin and remote hosts must never reach it. Checked BEFORE the
168
+ // generic `/_ws` inspector branch since `/_ws/acp`.startsWith('/_ws').
169
+ if (pathname === '/_ws/acp') {
170
+ if (!isLoopbackHost(req.headers.get('host'))) {
171
+ return new Response('ACP chat is loopback-only', { status: 403 });
172
+ }
173
+ const ok = srv.upgrade(req, {
174
+ data: {
175
+ id: crypto.randomUUID(),
176
+ remote: req.headers.get('x-forwarded-for') ?? '127.0.0.1',
177
+ kind: 'acp',
178
+ },
179
+ });
180
+ if (ok) return undefined as unknown as Response;
181
+ return new Response('Upgrade failed', { status: 400 });
182
+ }
183
+
144
184
  // Legacy inspector WS — JSON frames, designer-facing live tab state.
145
185
  if (pathname.startsWith('/_ws')) {
146
186
  const ok = srv.upgrade(req, {
@@ -339,6 +379,19 @@ await Bun.write(
339
379
  fsWatch.start();
340
380
  startHeapWatch();
341
381
 
382
+ // Phase 27 Task 5 (E2) — live dirty-state. Subscribes to `fs:any` (after
383
+ // fsWatch.start so it sees every event) and broadcasts `git-status` to the shell
384
+ // on each versionable change. No-op for a non-git project (gitStatus → repo:false).
385
+ const gitWatch = createGitWatch(ctx);
386
+
387
+ // Phase 31 follow-up — external-canvas list watcher. Subscribes to `fs:any` and
388
+ // emits `canvas-list-update` when a canvas file appears/disappears on disk from
389
+ // OUTSIDE the dev-server (ACP agent `/design:new`, agent Write, git checkout),
390
+ // so the browser file tree refreshes without a reload — the symmetric
391
+ // counterpart to api.ts's create/delete emit. RCA:
392
+ // `.ai/logs/rca/issue-acp-new-canvas-not-in-filetree.md`.
393
+ const canvasListWatch = createCanvasListWatch(ctx);
394
+
342
395
  // Phase 9 Task 4 — bidirectional sync agent. No-op when the project isn't
343
396
  // linked to a hub (`.design/config.json` has no `linkedHub` field). Kicked
344
397
  // off after fsWatch so the agent's bus subscription receives every fs event.
@@ -371,6 +424,16 @@ if (!process.env.NO_OPEN) {
371
424
  async function shutdown() {
372
425
  console.log('\n Stopping…');
373
426
  fsWatch.stop();
427
+ try {
428
+ gitWatch.stop();
429
+ } catch {
430
+ /* timer cleanup is best-effort */
431
+ }
432
+ try {
433
+ canvasListWatch.stop();
434
+ } catch {
435
+ /* timer cleanup is best-effort */
436
+ }
374
437
  try {
375
438
  activity.stop();
376
439
  } catch {
@@ -50,17 +50,27 @@ import {
50
50
  commentsFromDoc,
51
51
  cssFromDoc,
52
52
  htmlFromDoc,
53
+ markSeeded,
53
54
  mergeSharedMetaIntoLocal,
54
55
  metaFromDoc,
56
+ seededByFromDoc,
55
57
  stampBodyEdit,
56
58
  Y_SYNC_TYPES,
57
59
  } from './codec.ts';
58
- import { decideColdStart, unionCommentsById } from './cold-start.ts';
60
+ import { decideColdStart, isExactRepeat, unionCommentsById } from './cold-start.ts';
59
61
  import { type EchoGuard, hashBytes } from './echo-guard.ts';
60
62
  import type { SyncJournal } from './journal.ts';
61
63
 
62
64
  export const DOC_FLUSH_MS = 800;
63
65
 
66
+ /**
67
+ * Window after a `seed-local-up` during which this agent will repair a
68
+ * concurrent-seed duplication (F1). Bounded so the repair only ever fires for
69
+ * the cold-start race — a genuine peer edit that arrives minutes later is NEVER
70
+ * collapsed back to our original seed. Generous relative to the hub sync RTT.
71
+ */
72
+ export const SEED_REPAIR_WINDOW_MS = 10_000;
73
+
64
74
  export interface CanvasSyncPaths {
65
75
  /** Absolute path to <designRoot>/<canvas>.html. */
66
76
  html: string;
@@ -164,10 +174,21 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
164
174
  let lastMeta: string | null = null;
165
175
  let lastCss: string | null = null;
166
176
 
177
+ // F1 — the body this agent pushed on `seed-local-up`, plus the deadline past
178
+ // which the de-dup repair stops firing. Set on seed; nulled only when the
179
+ // window lapses (after convergence the repair is a cheap no-op — the
180
+ // `body === seedInfo.body` guard short-circuits, so we don't bother clearing).
181
+ // Null = this agent never seeded (so it never repairs).
182
+ let seedInfo: { body: string; until: number } | null = null;
183
+
167
184
  function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
168
185
  if (stopped) return;
169
186
  // Self-applied (we just synced from disk) — disk is already current.
170
187
  if (updateOrigin === origin) return;
188
+ // F1 — a remote update during the seed window may have concatenated a
189
+ // concurrent peer's identical seed onto ours. Collapse it (single elected
190
+ // writer) BEFORE the flush below can mirror a doubled body to disk.
191
+ maybeRepairSeedDuplication();
171
192
  scheduleFlush();
172
193
  }
173
194
 
@@ -188,6 +209,45 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
188
209
  }, flushMs);
189
210
  }
190
211
 
212
+ /**
213
+ * F1 — collapse a concurrent cold-seed duplication. When two peers
214
+ * `seed-local-up` the SAME body into an empty hub at the same instant, the
215
+ * merged Y.Text holds both insertions (`BODY` × N — un-buildable). This runs
216
+ * on every remote update inside the seed window and, on the single peer the
217
+ * `seededBy` Y.Map LWW elected, re-applies the canonical body so
218
+ * `applyHtmlToDoc`'s diff deletes the trailing duplicate(s). Only the elected
219
+ * owner repairs → the collapse op is emitted once and converges across peers
220
+ * (a non-owner just receives the delete). Restricted to an EXACT repeat of our
221
+ * seed: a divergent edit carries genuine bytes, so we DON'T touch it here —
222
+ * silently collapsing it would discard content. NOTE: that leaves the rare
223
+ * "two DIFFERENT bodies seeded into one empty hub at the same instant" case
224
+ * un-buildable until the NEXT boot, when `decideColdStart` routes it through
225
+ * the conflict path (dual snapshot + newest-wins). This live repair only
226
+ * covers the identical-seed race (the F1 RCA's scenario); the divergent
227
+ * variant is an accepted follow-up, not handled in-flight.
228
+ */
229
+ function maybeRepairSeedDuplication(): void {
230
+ if (!seedInfo) return;
231
+ if (Date.now() > seedInfo.until) {
232
+ seedInfo = null;
233
+ return;
234
+ }
235
+ const owner = seededByFromDoc(doc);
236
+ if (owner === null || owner !== doc.clientID) return; // not the elected writer
237
+ const body = htmlFromDoc(doc);
238
+ if (body === seedInfo.body) return; // already a single copy
239
+ if (!isExactRepeat(body, seedInfo.body)) return; // divergent edit → conflict path owns it
240
+ const canonical = seedInfo.body;
241
+ doc.transact(() => {
242
+ if (applyHtmlToDoc(doc, canonical, origin)) stampBodyEdit(doc, origin);
243
+ }, origin);
244
+ lastHtml = canonical;
245
+ opts.journal?.record(slug, { bodyHash: hashBytes(canonical) });
246
+ console.warn(
247
+ `[sync/${slug}] concurrent cold-seed duplication detected — collapsed to one copy (F1).`
248
+ );
249
+ }
250
+
191
251
  async function flush(): Promise<void> {
192
252
  if (!dirty || stopped) return;
193
253
  dirty = false;
@@ -348,7 +408,12 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
348
408
  if (localHtml !== null) {
349
409
  doc.transact(() => {
350
410
  if (applyHtmlToDoc(doc, localHtml, origin)) stampBodyEdit(doc, origin);
411
+ // F1 — adopt is also a "seed local up" (first link / fresh hub); claim
412
+ // it + arm the repair window so two peers adopting the same draft into
413
+ // one hub at once self-heal the same way the decision-table seed does.
414
+ markSeeded(doc, origin);
351
415
  }, origin);
416
+ seedInfo = { body: localHtml, until: Date.now() + SEED_REPAIR_WINDOW_MS };
352
417
  }
353
418
  if (localComments !== null) {
354
419
  const parsed = tryParseJsonArray(localComments);
@@ -396,8 +461,15 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
396
461
  const seedBodyUp = (body: string): void => {
397
462
  doc.transact(() => {
398
463
  if (applyHtmlToDoc(doc, body, origin)) stampBodyEdit(doc, origin);
464
+ // F1 — claim the seed (clientID marker) in the SAME update so a
465
+ // concurrent second seeder's merge resolves a single elected writer.
466
+ markSeeded(doc, origin);
399
467
  }, origin);
400
468
  lastHtml = body;
469
+ // Arm the de-dup repair window: if another peer seeded the same body at the
470
+ // same instant, the merged Y.Text will double — maybeRepairSeedDuplication
471
+ // (on the elected owner) collapses it back during this window.
472
+ seedInfo = { body, until: Date.now() + SEED_REPAIR_WINDOW_MS };
401
473
  opts.journal?.record(slug, { bodyHash: hashBytes(body) });
402
474
  };
403
475
 
@@ -421,6 +493,14 @@ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncA
421
493
  seedBodyUp(localHtml as string);
422
494
  bodyWinner = 'local';
423
495
  break;
496
+ case 'recover-seed-dup':
497
+ // F1 — booting against a hub whose body is our local body repeated (a
498
+ // concurrent cold-seed that already duplicated). Re-apply local so the
499
+ // diff deletes the extra copy/copies; the content equals local, so the
500
+ // visually-coupled lanes follow local too. Idempotent across peers.
501
+ seedBodyUp(localHtml as string);
502
+ bodyWinner = 'local';
503
+ break;
424
504
  case 'conflict': {
425
505
  // Divergence: snapshot BOTH versions to `_history/<slug>/` BEFORE any
426
506
  // write, then apply the newest-wins winner. Even a wrong pick costs
@@ -338,6 +338,30 @@ export function bodyEditAtFromDoc(doc: Y.Doc): number | null {
338
338
  return typeof v === 'number' && Number.isFinite(v) ? v : null;
339
339
  }
340
340
 
341
+ /**
342
+ * Record THIS doc's clientID as the seeder in `syncMeta.seededBy` (F1). Call it
343
+ * in the SAME transaction + origin as a `seed-local-up` body apply. The value is
344
+ * a Yjs clientID, so when two peers seed the same empty hub simultaneously and
345
+ * their maps merge, Y.Map's deterministic last-writer-wins resolution converges
346
+ * `seededBy` to ONE clientID on EVERY peer — that elected peer is the
347
+ * single-writer that performs the de-duplication repair, so the collapse op is
348
+ * never emitted twice. Informational/bookkeeping only; never materialized to
349
+ * disk (syncMeta is a sync-internal lane).
350
+ */
351
+ export function markSeeded(doc: Y.Doc, origin?: unknown): void {
352
+ const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
353
+ doc.transact(() => {
354
+ map.set('seededBy', doc.clientID);
355
+ }, origin);
356
+ }
357
+
358
+ /** The elected seeder's clientID (`syncMeta.seededBy`), or null when no peer
359
+ * ever seeded through the marker path (older peers / non-seed canvases). */
360
+ export function seededByFromDoc(doc: Y.Doc): number | null {
361
+ const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('seededBy');
362
+ return typeof v === 'number' && Number.isFinite(v) ? v : null;
363
+ }
364
+
341
365
  /* ---------------------------------------------------------------- css */
342
366
 
343
367
  /** The synced canvas CSS string held in the doc, or null when unset/empty. */
@@ -26,6 +26,7 @@ export type ColdStartAction =
26
26
  | 'materialize-hub'
27
27
  | 'seed-local-up'
28
28
  | 'fast-forward-hub'
29
+ | 'recover-seed-dup'
29
30
  | 'conflict';
30
31
 
31
32
  export interface ColdStartInput {
@@ -56,6 +57,24 @@ function isEmptyBody(body: string | null): boolean {
56
57
  return body === null || body.trim() === '';
57
58
  }
58
59
 
60
+ /**
61
+ * True when `docBody` is exactly `localBody` repeated N≥2 times — the signature
62
+ * of a concurrent cold-seed collision (F1): two peers each `seed-local-up`-ed
63
+ * the SAME body into an empty hub before either's write propagated, so the CRDT
64
+ * preserved both insertions and the Y.Text became `BODY` × N. We detect the
65
+ * exact-repeat shape (not a fuzzy "contains") so a legitimate later edit — which
66
+ * is never a clean integer-multiple repeat of the prior body — can't be
67
+ * mis-read as a duplication and clobbered back. Exact equality (N==1) is the
68
+ * caller's `noop`, handled before this is consulted.
69
+ */
70
+ export function isExactRepeat(docBody: string, localBody: string): boolean {
71
+ if (localBody.length === 0) return false;
72
+ if (docBody.length <= localBody.length) return false;
73
+ if (docBody.length % localBody.length !== 0) return false;
74
+ const n = docBody.length / localBody.length;
75
+ return docBody === localBody.repeat(n);
76
+ }
77
+
59
78
  function commentId(c: unknown): string | null {
60
79
  if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
61
80
  return (c as { id: string }).id;
@@ -117,6 +136,27 @@ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
117
136
  return { action: 'noop', reason: 'local and hub identical' };
118
137
  }
119
138
 
139
+ // Concurrent cold-seed collision (F1): the hub body is our local body repeated
140
+ // N≥2 times — two peers seeded the same canvas into an empty hub at the same
141
+ // moment and the CRDT concatenated both insertions (un-buildable: two `export
142
+ // default`). This is NOT divergence (it carries no new bytes — just a doubled
143
+ // copy of ours), so it must NOT take the conflict/fast-forward path. Collapse
144
+ // it back to one copy by re-applying local (the caller's `applyHtmlToDoc` diff
145
+ // deletes the trailing duplicate). Idempotent across peers — the delete targets
146
+ // the same CRDT items, so concurrent recoveries converge instead of fighting.
147
+ if (
148
+ input.localBody !== null &&
149
+ !localEmpty &&
150
+ !docEmpty &&
151
+ isExactRepeat(input.docBody, input.localBody)
152
+ ) {
153
+ return {
154
+ action: 'recover-seed-dup',
155
+ reason:
156
+ 'hub body is local repeated — concurrent cold-seed duplication; collapsing to one copy',
157
+ };
158
+ }
159
+
120
160
  // Hash via hashBytes ONLY — the journal recorded its hashes through the same
121
161
  // fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
122
162
  if (
@@ -0,0 +1,137 @@
1
+ // sync/hub-link.ts — Phase 29 (E4) Door C: connect to a team hub from the wizard.
2
+ //
3
+ // The CLI (`maude design link`) owns the FULL link flow (interactive trust gate +
4
+ // per-project `.design/config.json` linkedHub write + `--adopt` push). This is the
5
+ // lean in-app counterpart used by the onboarding wizard's advanced door: it saves the
6
+ // hub CREDENTIAL to the global `~/.config/maude/hubs.json` (mode 0600) and records the
7
+ // hub as trusted on THIS machine — the in-UI "Connect" IS the explicit trust grant the
8
+ // CLI's interactive confirmation provides (DDR-054 F2). Hub tokens are GLOBAL /
9
+ // per-machine (keyed by normalized URL), so a project later opened whose
10
+ // `.design/config.json` names this hub syncs using the saved token. We deliberately do
11
+ // NOT write a per-project `linkedHub` here (onboarding has no project yet) — that stays
12
+ // a CLI / post-onboarding operation.
13
+ //
14
+ // SECURITY: the http layer gates this main-origin + loopback only (mirrors
15
+ // /_api/github/*). The health probe is a best-effort, TOKENLESS GET to the user-entered
16
+ // hub URL; only `{ ok, version }` is reflected back — no response-body passthrough, so it
17
+ // can't become an SSRF data-exfil channel. The probe deliberately does NOT carry the hub
18
+ // token: a user who pastes a lookalike URL must not have their credential delivered to the
19
+ // attacker's host (phase-29 /flow:done attacker finding A2). The token is only persisted
20
+ // locally (mode 0600) and presented later on the authenticated sync WS upgrade. The hub
21
+ // address is whatever the user typed (a LAN / Tailscale / fly.dev URL is legitimate —
22
+ // phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
23
+ // is the loopback caller gate + no reflection + no token on the probe.
24
+
25
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { dirname } from 'node:path';
27
+
28
+ import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
29
+
30
+ export interface HubLinkResult {
31
+ status: number;
32
+ json: unknown;
33
+ }
34
+
35
+ const HUB_PROBE_TIMEOUT_MS = 4000;
36
+
37
+ interface HubsFile {
38
+ hubs: Record<string, { token: string; linkedAt: number }>;
39
+ trusted?: string[];
40
+ }
41
+
42
+ /** Validate + probe + persist a hub credential. Returns an http-shaped result. */
43
+ export async function linkHub(body: unknown): Promise<HubLinkResult> {
44
+ const b = (body ?? {}) as { url?: unknown; token?: unknown };
45
+ if (typeof b.url !== 'string' || !b.url.trim()) return bad('Enter the hub address.');
46
+ if (typeof b.token !== 'string' || !b.token.trim())
47
+ return bad('Paste the invite link or token your team gave you.');
48
+
49
+ let norm: string;
50
+ try {
51
+ norm = normalizeUrl(b.url.trim());
52
+ } catch {
53
+ return bad("That doesn't look like a valid hub address.");
54
+ }
55
+ let parsed: URL;
56
+ try {
57
+ parsed = new URL(norm);
58
+ } catch {
59
+ return bad('Invalid hub address.');
60
+ }
61
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
62
+ return bad('The hub address must start with http:// or https://.');
63
+
64
+ const token = b.token.trim();
65
+
66
+ // Best-effort reachability probe — TOKENLESS (never deliver the credential to a
67
+ // possibly-lookalike host) and does NOT gate the save (a firewalled hub the user
68
+ // trusts still links; the real auth happens on the sync WS upgrade).
69
+ const probe = await probeHealth(norm);
70
+
71
+ try {
72
+ saveHubCredential(norm, token);
73
+ } catch {
74
+ return { status: 500, json: { ok: false, error: "Couldn't save the hub connection." } };
75
+ }
76
+
77
+ return {
78
+ status: 200,
79
+ json: { ok: true, url: norm, healthy: probe.ok, version: probe.version ?? null },
80
+ };
81
+ }
82
+
83
+ function bad(error: string): HubLinkResult {
84
+ return { status: 400, json: { ok: false, error } };
85
+ }
86
+
87
+ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string }> {
88
+ const ctrl = new AbortController();
89
+ const timer = setTimeout(() => ctrl.abort(), HUB_PROBE_TIMEOUT_MS);
90
+ try {
91
+ // Tokenless on purpose (attacker finding A2): /health is a liveness check; the
92
+ // credential is never sent to the user-typed host, only to the trusted hub on the
93
+ // authenticated sync WS upgrade later.
94
+ const res = await fetch(`${url}/health`, { signal: ctrl.signal });
95
+ if (!res.ok) return { ok: false };
96
+ let version: string | undefined;
97
+ try {
98
+ const j = (await res.json()) as { version?: unknown };
99
+ if (j && typeof j.version === 'string') version = j.version;
100
+ } catch {
101
+ /* health may not be JSON — reachability alone is enough */
102
+ }
103
+ return { ok: true, version };
104
+ } catch {
105
+ return { ok: false };
106
+ } finally {
107
+ clearTimeout(timer);
108
+ }
109
+ }
110
+
111
+ /** Upsert the token under `normUrl` + record per-machine trust; mode 0600. */
112
+ export function saveHubCredential(normUrl: string, token: string): void {
113
+ const path = hubsConfigPath();
114
+ const dir = dirname(path);
115
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
116
+ let cfg: HubsFile = { hubs: {} };
117
+ if (existsSync(path)) {
118
+ try {
119
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
120
+ if (parsed && typeof parsed.hubs === 'object' && parsed.hubs !== null)
121
+ cfg = parsed as HubsFile;
122
+ } catch {
123
+ /* malformed → start fresh rather than throw */
124
+ }
125
+ }
126
+ cfg.hubs[normUrl] = { token, linkedAt: Date.now() };
127
+ if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
128
+ if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
129
+ writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
130
+ try {
131
+ chmodSync(path, 0o600);
132
+ } catch {
133
+ /* windows / read-only fs — best effort */
134
+ }
135
+ }
136
+
137
+ export const __testing = { probeHealth };