@1agh/maude 0.29.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 (162) hide show
  1. package/README.md +6 -6
  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/ai-banner.tsx +0 -1
  8. package/apps/studio/annotations-align.ts +149 -0
  9. package/apps/studio/annotations-bindings.ts +197 -0
  10. package/apps/studio/annotations-context-toolbar.tsx +436 -186
  11. package/apps/studio/annotations-groups.ts +270 -0
  12. package/apps/studio/annotations-layer.tsx +1714 -1661
  13. package/apps/studio/annotations-model.ts +2077 -0
  14. package/apps/studio/annotations-snap.ts +125 -0
  15. package/apps/studio/api.ts +433 -182
  16. package/apps/studio/bin/_png-playwright.mjs +1 -1
  17. package/apps/studio/bin/annotate.mjs +732 -0
  18. package/apps/studio/bin/annotate.sh +17 -0
  19. package/apps/studio/bin/chat-open.sh +44 -0
  20. package/apps/studio/bin/read-annotations.mjs +152 -17
  21. package/apps/studio/build.ts +1 -1
  22. package/apps/studio/canvas-arrowheads.ts +78 -9
  23. package/apps/studio/canvas-cursors.ts +2 -0
  24. package/apps/studio/canvas-edit.ts +257 -7
  25. package/apps/studio/canvas-icons.tsx +105 -0
  26. package/apps/studio/canvas-lib.tsx +112 -19
  27. package/apps/studio/canvas-list-watch.ts +177 -0
  28. package/apps/studio/canvas-shell.tsx +326 -9
  29. package/apps/studio/client/app.jsx +3579 -250
  30. package/apps/studio/client/canvas-url.js +5 -0
  31. package/apps/studio/client/github.js +99 -0
  32. package/apps/studio/client/index.html +1 -1
  33. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  34. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  35. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  36. package/apps/studio/client/panels/DiffView.jsx +590 -0
  37. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  38. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  39. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  40. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  41. package/apps/studio/client/panels/acp-runtime.js +286 -0
  42. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  43. package/apps/studio/client/panels/git-grouping.js +86 -0
  44. package/apps/studio/client/styles/0-reset.css +4 -0
  45. package/apps/studio/client/styles/3-shell-maude.css +1326 -14
  46. package/apps/studio/client/styles/4-components.css +39 -0
  47. package/apps/studio/client/styles/5-maude-overrides.css +41 -2
  48. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  49. package/apps/studio/client/styles/_index.css +2 -0
  50. package/apps/studio/client/tour/collab-tour.js +61 -0
  51. package/apps/studio/client/tour/overlay.jsx +116 -18
  52. package/apps/studio/client/tour/usage-tour.js +23 -10
  53. package/apps/studio/client/whats-new.jsx +25 -10
  54. package/apps/studio/collab/registry.ts +13 -0
  55. package/apps/studio/collab/room.ts +36 -0
  56. package/apps/studio/commands/annotation-strokes-command.ts +1 -1
  57. package/apps/studio/commands/move-artboards-command.ts +1 -1
  58. package/apps/studio/comments-overlay.tsx +7 -5
  59. package/apps/studio/context-menu.tsx +4 -3
  60. package/apps/studio/contextual-toolbar.tsx +14 -0
  61. package/apps/studio/cursors-overlay.tsx +37 -21
  62. package/apps/studio/dist/client.bundle.js +31795 -1829
  63. package/apps/studio/dist/comment-mount.js +97 -9
  64. package/apps/studio/dist/styles.css +7938 -1599
  65. package/apps/studio/dom-selection.ts +115 -0
  66. package/apps/studio/export-dialog.tsx +3 -3
  67. package/apps/studio/git/endpoints.ts +338 -0
  68. package/apps/studio/git/service.ts +1334 -0
  69. package/apps/studio/git/watch.ts +97 -0
  70. package/apps/studio/github/endpoints.ts +358 -0
  71. package/apps/studio/github/service.ts +231 -0
  72. package/apps/studio/github/token.ts +53 -0
  73. package/apps/studio/history.ts +20 -3
  74. package/apps/studio/hmr-broadcast.ts +9 -2
  75. package/apps/studio/http.ts +508 -6
  76. package/apps/studio/input-router.tsx +7 -0
  77. package/apps/studio/inspect.ts +37 -8
  78. package/apps/studio/participants-chrome.tsx +70 -16
  79. package/apps/studio/paths.ts +12 -0
  80. package/apps/studio/scaffold-design.ts +57 -0
  81. package/apps/studio/server.ts +70 -4
  82. package/apps/studio/sync/agent.ts +311 -57
  83. package/apps/studio/sync/codec.ts +69 -0
  84. package/apps/studio/sync/cold-start.ts +198 -0
  85. package/apps/studio/sync/connection-state.ts +58 -2
  86. package/apps/studio/sync/hub-link.ts +137 -0
  87. package/apps/studio/sync/index.ts +563 -221
  88. package/apps/studio/sync/journal.ts +190 -0
  89. package/apps/studio/sync/migrate-seed.ts +202 -44
  90. package/apps/studio/sync/projection.ts +22 -3
  91. package/apps/studio/sync/status.ts +15 -4
  92. package/apps/studio/test/acp-bridge.test.ts +127 -0
  93. package/apps/studio/test/acp-env.test.ts +65 -0
  94. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  95. package/apps/studio/test/acp-transcript.test.ts +112 -0
  96. package/apps/studio/test/activity.test.ts +1 -7
  97. package/apps/studio/test/annotate-write.test.ts +184 -0
  98. package/apps/studio/test/annotations-align.test.ts +88 -0
  99. package/apps/studio/test/annotations-bindings.test.ts +124 -0
  100. package/apps/studio/test/annotations-groups.test.ts +231 -0
  101. package/apps/studio/test/annotations-snap.test.ts +79 -0
  102. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  103. package/apps/studio/test/canvas-edit.test.ts +181 -1
  104. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  105. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  106. package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
  107. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  108. package/apps/studio/test/collab-bridge.test.ts +0 -2
  109. package/apps/studio/test/collab-room.test.ts +2 -7
  110. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  111. package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
  112. package/apps/studio/test/csrf-write-guard.test.ts +78 -0
  113. package/apps/studio/test/editing-presence.test.ts +103 -0
  114. package/apps/studio/test/figjam-v3-model.test.ts +342 -0
  115. package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
  116. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  117. package/apps/studio/test/git-api.test.ts +0 -0
  118. package/apps/studio/test/git-branches.test.ts +106 -0
  119. package/apps/studio/test/git-grouping.test.ts +106 -0
  120. package/apps/studio/test/git-watch.test.ts +97 -0
  121. package/apps/studio/test/github-api.test.ts +465 -0
  122. package/apps/studio/test/hub-link.test.ts +69 -0
  123. package/apps/studio/test/participants-chrome.test.ts +36 -1
  124. package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
  125. package/apps/studio/test/sync-agent.test.ts +272 -2
  126. package/apps/studio/test/sync-codec.test.ts +65 -0
  127. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  128. package/apps/studio/test/sync-cold-start.test.ts +304 -0
  129. package/apps/studio/test/sync-connection-state.test.ts +68 -0
  130. package/apps/studio/test/sync-hardening.test.ts +0 -8
  131. package/apps/studio/test/sync-hubs-config.test.ts +0 -1
  132. package/apps/studio/test/sync-incident-replay.test.ts +211 -0
  133. package/apps/studio/test/sync-journal.test.ts +176 -0
  134. package/apps/studio/test/sync-runtime.test.ts +360 -5
  135. package/apps/studio/test/sync-status.test.ts +66 -0
  136. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  137. package/apps/studio/test/use-annotation-resize.test.ts +159 -0
  138. package/apps/studio/test/use-artboard-drag.test.ts +0 -1
  139. package/apps/studio/test/use-collab.test.ts +0 -0
  140. package/apps/studio/test/use-tool-mode.test.tsx +5 -2
  141. package/apps/studio/tool-palette.tsx +30 -11
  142. package/apps/studio/undo-hud.tsx +0 -1
  143. package/apps/studio/use-agent-presence.tsx +4 -2
  144. package/apps/studio/use-annotation-resize.tsx +484 -54
  145. package/apps/studio/use-annotation-selection.tsx +16 -4
  146. package/apps/studio/use-canvas-activity.tsx +2 -2
  147. package/apps/studio/use-chrome-visibility.tsx +66 -0
  148. package/apps/studio/use-collab.tsx +434 -202
  149. package/apps/studio/use-selection-set.tsx +9 -0
  150. package/apps/studio/use-tool-mode.tsx +5 -1
  151. package/apps/studio/whats-new.json +208 -10
  152. package/apps/studio/whats-new.schema.json +18 -2
  153. package/apps/studio/ws.ts +44 -1
  154. package/cli/commands/design-link.test.mjs +84 -0
  155. package/cli/commands/design.mjs +3 -1
  156. package/cli/commands/design.test.mjs +5 -1
  157. package/cli/lib/design-link.mjs +51 -1
  158. package/cli/lib/gitignore-block.mjs +16 -3
  159. package/cli/lib/gitignore-block.test.mjs +13 -1
  160. package/package.json +11 -9
  161. package/plugins/design/dependencies.json +17 -0
  162. package/plugins/design/templates/_shell.html +58 -12
@@ -0,0 +1,198 @@
1
+ // Cold-start body resolution — the pure decision core of the conflict
2
+ // protocol (DDR-102; supersedes the v1.1 "hub-wins always" scoping).
3
+ //
4
+ // Both sync paths (agent.ts reconcile, migrate-seed.ts for the shared-doc
5
+ // cutover) feed their observed state into `decideColdStart` and execute the
6
+ // returned action. Keeping the table pure makes the full matrix unit-testable
7
+ // without Y.Docs or disk (mirrors codec.ts's pure-function style).
8
+ //
9
+ // The journal (journal.ts) is what separates a CLEAN catch-up from divergence:
10
+ // journalHash == hash(local) means every local byte was already reconciled
11
+ // through this machine — the hub being different just means it moved ahead, so
12
+ // overwriting disk is a fast-forward, not data loss. Any other combination of
13
+ // "both sides non-empty and different" is a conflict: the caller snapshots
14
+ // BOTH versions to `_history/<slug>/` and applies the newest-wins winner.
15
+ //
16
+ // Newest-wins compares the doc-side `syncMeta.bodyEditAt` stamp (codec.ts —
17
+ // written by every peer that applies a local body into the doc) against the
18
+ // local file mtime. Either side unknown (older peer never stamped; mtime
19
+ // unavailable) or equal → hub wins, exactly the v1.1 default — but now both
20
+ // sides are snapshotted first, so even a wrong pick costs one /design:rollback.
21
+
22
+ import { hashBytes } from './echo-guard.ts';
23
+
24
+ export type ColdStartAction =
25
+ | 'noop'
26
+ | 'materialize-hub'
27
+ | 'seed-local-up'
28
+ | 'fast-forward-hub'
29
+ | 'recover-seed-dup'
30
+ | 'conflict';
31
+
32
+ export interface ColdStartInput {
33
+ /** Local body file content, or null when the file doesn't exist. */
34
+ localBody: string | null;
35
+ /** Body currently held by the doc (hub state after first sync). */
36
+ docBody: string;
37
+ /** Journal entry's bodyHash for this slug, or null when never checkpointed. */
38
+ journalHash: string | null;
39
+ /** Local body file mtime (ms epoch), or null when unavailable. */
40
+ localMtimeMs: number | null;
41
+ /** Doc-side syncMeta.bodyEditAt stamp (ms epoch), or null when no peer ever
42
+ * stamped (older peer interop) — falls back to hub-wins. */
43
+ docBodyEditAtMs: number | null;
44
+ }
45
+
46
+ export interface ColdStartDecision {
47
+ action: ColdStartAction;
48
+ /** Set only for `conflict`. */
49
+ winner?: 'local' | 'hub';
50
+ /** Human-readable, logged + recorded in the conflict entry. */
51
+ reason: string;
52
+ }
53
+
54
+ /** Trim-only-whitespace bodies count as empty (mirrors the agent's
55
+ * `localHtml.trim() !== ''` guard). */
56
+ function isEmptyBody(body: string | null): boolean {
57
+ return body === null || body.trim() === '';
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
+
78
+ function commentId(c: unknown): string | null {
79
+ if (c && typeof c === 'object' && typeof (c as { id?: unknown }).id === 'string') {
80
+ return (c as { id: string }).id;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Union-merge two comment snapshots by stable `id` (DDR-102): doc order first,
87
+ * local-only entries appended. Comments carry stable ids, so union loses
88
+ * nothing and needs no winner — same-id entries keep the doc's version.
89
+ * Id-less entries dedupe by JSON identity (conservative). Shared by both
90
+ * cold-start paths (agent reconcile + migrate-seed).
91
+ */
92
+ export function unionCommentsById(docList: unknown[], localList: unknown[]): unknown[] {
93
+ const out = [...docList];
94
+ const seenIds = new Set<string>();
95
+ const seenJson = new Set<string>();
96
+ for (const c of docList) {
97
+ const id = commentId(c);
98
+ if (id !== null) seenIds.add(id);
99
+ else seenJson.add(JSON.stringify(c));
100
+ }
101
+ for (const c of localList) {
102
+ const id = commentId(c);
103
+ const dup = id !== null ? seenIds.has(id) : seenJson.has(JSON.stringify(c));
104
+ if (dup) continue;
105
+ out.push(c);
106
+ if (id !== null) seenIds.add(id);
107
+ else seenJson.add(JSON.stringify(c));
108
+ }
109
+ return out;
110
+ }
111
+
112
+ export function decideColdStart(input: ColdStartInput): ColdStartDecision {
113
+ const localEmpty = isEmptyBody(input.localBody);
114
+ const docEmpty = isEmptyBody(input.docBody);
115
+
116
+ if (localEmpty && docEmpty) {
117
+ return { action: 'noop', reason: 'both sides empty — nothing to reconcile' };
118
+ }
119
+
120
+ if (localEmpty) {
121
+ // Clean first sync: nothing local to lose.
122
+ return { action: 'materialize-hub', reason: 'no local body — materializing hub state' };
123
+ }
124
+
125
+ if (docEmpty) {
126
+ // DDR-064 empty-hub guard, now a named case: an empty hub doc means the hub
127
+ // holds no body for this slug yet — NOT an authoritative "blank canvas".
128
+ return {
129
+ action: 'seed-local-up',
130
+ reason: 'hub doc empty — seeding local body up (DDR-064 guard)',
131
+ };
132
+ }
133
+
134
+ if (input.localBody === input.docBody) {
135
+ // Caller records the journal so the NEXT boot sees a clean checkpoint.
136
+ return { action: 'noop', reason: 'local and hub identical' };
137
+ }
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
+
160
+ // Hash via hashBytes ONLY — the journal recorded its hashes through the same
161
+ // fn (single source, echo-guard.ts), so this comparison is apples-to-apples.
162
+ if (
163
+ input.journalHash !== null &&
164
+ input.localBody !== null &&
165
+ input.journalHash === hashBytes(input.localBody)
166
+ ) {
167
+ // Everything local was already reconciled through this machine; the hub is
168
+ // simply ahead. Overwrite WITHOUT snapshot/conflict — a clean fast-forward.
169
+ return {
170
+ action: 'fast-forward-hub',
171
+ reason: 'local matches last-synced journal hash — hub is ahead, fast-forwarding',
172
+ };
173
+ }
174
+
175
+ // Divergence: both sides non-empty, different, and local carries bytes this
176
+ // machine never reconciled (journal stale or absent). Newest wins; unknown
177
+ // or tied timestamps fall back to hub (v1.1 default, now recoverable).
178
+ const local = input.localMtimeMs;
179
+ const doc = input.docBodyEditAtMs;
180
+ if (local !== null && doc !== null && local !== doc) {
181
+ const winner = local > doc ? 'local' : 'hub';
182
+ return {
183
+ action: 'conflict',
184
+ winner,
185
+ reason: `diverged — newest wins: local mtime ${new Date(local).toISOString()} ${
186
+ winner === 'local' ? '>' : '<'
187
+ } doc bodyEditAt ${new Date(doc).toISOString()}`,
188
+ };
189
+ }
190
+ return {
191
+ action: 'conflict',
192
+ winner: 'hub',
193
+ reason:
194
+ local === null || doc === null
195
+ ? 'diverged — timestamp unknown on one side, falling back to hub-wins (recoverable: both sides snapshotted)'
196
+ : 'diverged — timestamps tied, falling back to hub-wins (recoverable: both sides snapshotted)',
197
+ };
198
+ }
@@ -23,11 +23,18 @@
23
23
  export type ProviderStatus = 'connected' | 'connecting' | 'disconnected';
24
24
  export type SyncState = 'online' | 'connecting' | 'offline' | 'offline-long';
25
25
 
26
+ /** DDR-102 — per-document sync state. `pending` until the first handshake
27
+ * settles; `connected` once synced/active; `auth-rejected` when the hub
28
+ * refused auth for this documentName (scope / invalid token / rate limit). */
29
+ export type DocSyncState = 'pending' | 'connected' | 'auth-rejected';
30
+
26
31
  export interface SyncStatusSnapshot {
27
32
  state: SyncState;
28
33
  /** Local edits made since the hub went unreachable (replayed on reconnect). */
29
34
  queuedOps: number;
30
- /** ms epoch of the last successful hub sync, or null if never synced. */
35
+ /** ms epoch of the last successful hub sync, or null if never synced.
36
+ * DDR-102: updated on REAL sync activity (noteSyncActivity), not just on
37
+ * offline→online transitions. */
31
38
  lastSyncAt: number | null;
32
39
  /** ms epoch the current offline streak began, or null when online. */
33
40
  offlineSince: number | null;
@@ -35,6 +42,11 @@ export interface SyncStatusSnapshot {
35
42
  flash: 'synced' | null;
36
43
  /** ms epoch this snapshot was produced. */
37
44
  updatedAt: number;
45
+ /** DDR-102 — per-doc rollup (additive; absent in pre-DDR-102 payloads). */
46
+ docs?: { synced: number; pending: number; rejected: number };
47
+ /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
48
+ * for the true count). Treat as text, never HTML. */
49
+ rejectedSlugs?: string[];
38
50
  }
39
51
 
40
52
  type TimerHandle = ReturnType<typeof setTimeout>;
@@ -58,6 +70,11 @@ export interface ConnectionMonitor {
58
70
  noteProviderStatus(providerId: string, status: ProviderStatus): void;
59
71
  /** A local edit happened — counts toward queuedOps while not online. */
60
72
  noteLocalEdit(): void;
73
+ /** DDR-102 — record a document's sync state (pending/connected/auth-rejected). */
74
+ noteDocState(slug: string, state: DocSyncState): void;
75
+ /** DDR-102 — real sync activity for a slug (reconcile done, hub-pushed flush
76
+ * applied): bumps `lastSyncAt` to now. */
77
+ noteSyncActivity(slug: string): void;
61
78
  /** Current snapshot (defensive copy). */
62
79
  snapshot(): SyncStatusSnapshot;
63
80
  /** Tear down timers. */
@@ -67,6 +84,8 @@ export interface ConnectionMonitor {
67
84
  const DEFAULT_GRACE_MS = 30_000;
68
85
  const DEFAULT_ESCALATE_MS = 24 * 60 * 60 * 1000;
69
86
  const DEFAULT_FLASH_MS = 3_000;
87
+ /** Cap on rejectedSlugs in the snapshot (the rollup carries the true count). */
88
+ export const MAX_REJECTED_SLUGS = 20;
70
89
 
71
90
  export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): ConnectionMonitor {
72
91
  const graceMs = opts.graceMs ?? DEFAULT_GRACE_MS;
@@ -78,6 +97,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
78
97
  const onChange = opts.onChange;
79
98
 
80
99
  const providerStatuses = new Map<string, ProviderStatus>();
100
+ // DDR-102 — per-doc states (pending/connected/auth-rejected).
101
+ const docStates = new Map<string, DocSyncState>();
81
102
 
82
103
  let state: SyncState = 'online';
83
104
  let queuedOps = 0;
@@ -91,7 +112,25 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
91
112
  let stopped = false;
92
113
 
93
114
  function snapshot(): SyncStatusSnapshot {
94
- return { state, queuedOps, lastSyncAt, offlineSince, flash, updatedAt: now() };
115
+ const docs = { synced: 0, pending: 0, rejected: 0 };
116
+ const rejectedSlugs: string[] = [];
117
+ for (const [slug, st] of docStates) {
118
+ if (st === 'connected') docs.synced++;
119
+ else if (st === 'auth-rejected') {
120
+ docs.rejected++;
121
+ if (rejectedSlugs.length < MAX_REJECTED_SLUGS) rejectedSlugs.push(slug);
122
+ } else docs.pending++;
123
+ }
124
+ return {
125
+ state,
126
+ queuedOps,
127
+ lastSyncAt,
128
+ offlineSince,
129
+ flash,
130
+ updatedAt: now(),
131
+ docs,
132
+ rejectedSlugs,
133
+ };
95
134
  }
96
135
 
97
136
  function emit(): void {
@@ -189,6 +228,23 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
189
228
  emit();
190
229
  },
191
230
 
231
+ noteDocState(slug, docState) {
232
+ if (stopped) return;
233
+ if (docStates.get(slug) === docState) return;
234
+ docStates.set(slug, docState);
235
+ emit();
236
+ },
237
+
238
+ noteSyncActivity(slug) {
239
+ if (stopped) return;
240
+ lastSyncAt = now();
241
+ // Real sync traffic for a pending doc proves its handshake settled.
242
+ // An auth-rejected doc stays rejected (activity for it can't happen,
243
+ // but be defensive against ordering races).
244
+ if (docStates.get(slug) === 'pending') docStates.set(slug, 'connected');
245
+ emit();
246
+ },
247
+
192
248
  snapshot,
193
249
 
194
250
  stop() {
@@ -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 };