@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.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- 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
|
-
|
|
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 };
|