@1agh/maude 0.48.0 → 0.49.1
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/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/http.ts +45 -0
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/gitignore-block.mjs +12 -1
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
|
@@ -13,17 +13,34 @@ import path from 'node:path';
|
|
|
13
13
|
import { Awareness, removeAwarenessStates } from 'y-protocols/awareness';
|
|
14
14
|
import * as Y from 'yjs';
|
|
15
15
|
|
|
16
|
+
import { isBodyLane, rootTypesTouched } from './origins.ts';
|
|
16
17
|
import {
|
|
18
|
+
applySyncMessage,
|
|
17
19
|
type CollabConn,
|
|
18
20
|
encodeAwarenessFrame,
|
|
19
21
|
encodeHandshake,
|
|
20
22
|
encodeSyncUpdate,
|
|
21
23
|
handleMessage,
|
|
24
|
+
MESSAGE_SYNC,
|
|
25
|
+
readMessageType,
|
|
22
26
|
} from './protocol.ts';
|
|
23
27
|
|
|
24
28
|
export interface RoomConn extends CollabConn {
|
|
25
29
|
/** Stable id for this connection (UUID from ws.data.id). */
|
|
26
30
|
id: string;
|
|
31
|
+
/**
|
|
32
|
+
* Which server origin upgraded this socket (DDR-063 canvas-origin split).
|
|
33
|
+
* `'canvas'` = the untrusted canvas iframe origin — its sync frames go
|
|
34
|
+
* through the origin gate (DDR-122 follow-up) and may never write a body
|
|
35
|
+
* lane. Absent or `'main'` = the privileged shell origin, ungated.
|
|
36
|
+
*
|
|
37
|
+
* Absent defaults to trusted ON PURPOSE: every pre-existing caller (tests,
|
|
38
|
+
* the shell socket) is main-origin, and the two canvas-origin call sites
|
|
39
|
+
* (`server.ts`'s `startCanvasServer` upgrade → `ws.ts`'s `bindCollab`) set
|
|
40
|
+
* it explicitly. `test/collab-origin-gate.test.ts` pins that wiring so the
|
|
41
|
+
* default can't silently swallow a future canvas path.
|
|
42
|
+
*/
|
|
43
|
+
realm?: 'main' | 'canvas';
|
|
27
44
|
}
|
|
28
45
|
|
|
29
46
|
export interface RoomCallbacks {
|
|
@@ -72,6 +89,12 @@ export interface Room {
|
|
|
72
89
|
destroy(): Promise<void>;
|
|
73
90
|
/** Test/inspection: connection count. */
|
|
74
91
|
size(): number;
|
|
92
|
+
/**
|
|
93
|
+
* Test/inspection: how many canvas-realm sync frames the origin gate has
|
|
94
|
+
* refused for touching a body lane. Non-zero in production means untrusted
|
|
95
|
+
* canvas script tried to write source — worth surfacing, never expected.
|
|
96
|
+
*/
|
|
97
|
+
gateRefusals(): number;
|
|
75
98
|
}
|
|
76
99
|
|
|
77
100
|
const DEBOUNCE_MS = 800;
|
|
@@ -133,9 +156,104 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
133
156
|
}
|
|
134
157
|
}
|
|
135
158
|
|
|
159
|
+
// --- Origin gate (DDR-122 follow-up) -------------------------------------
|
|
160
|
+
//
|
|
161
|
+
// A canvas-realm sync frame is applied to `mirror` FIRST, under a probe
|
|
162
|
+
// origin, so `transaction.changed` tells us which root lanes it targets.
|
|
163
|
+
// Only if it touches no body lane do the same bytes reach `doc`. See
|
|
164
|
+
// `collab/origins.ts` for why the update bytes alone can't answer this.
|
|
165
|
+
const GATE_PROBE_ORIGIN = Object.freeze({ maudeOrigin: 'collab-gate-probe' });
|
|
166
|
+
const GATE_MIRROR_SYNC = Object.freeze({ maudeOrigin: 'collab-gate-mirror-sync' });
|
|
167
|
+
let mirror: Y.Doc | null = null;
|
|
168
|
+
let probeRoots: Set<string> | null = null;
|
|
169
|
+
let gateRefusalCount = 0;
|
|
170
|
+
|
|
171
|
+
function ensureMirror(): Y.Doc {
|
|
172
|
+
if (mirror) return mirror;
|
|
173
|
+
const m = new Y.Doc();
|
|
174
|
+
Y.applyUpdate(m, Y.encodeStateAsUpdate(doc), GATE_MIRROR_SYNC);
|
|
175
|
+
m.on('afterTransaction', (tr: Y.Transaction) => {
|
|
176
|
+
if (tr.origin === GATE_PROBE_ORIGIN) probeRoots = rootTypesTouched(tr);
|
|
177
|
+
});
|
|
178
|
+
mirror = m;
|
|
179
|
+
return m;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Drop a mirror that has been poisoned by a refused frame. Rebuilt lazily
|
|
183
|
+
* from the (still clean) room doc on the next canvas-realm frame. */
|
|
184
|
+
function discardMirror(): void {
|
|
185
|
+
const m = mirror;
|
|
186
|
+
mirror = null;
|
|
187
|
+
if (m) {
|
|
188
|
+
try {
|
|
189
|
+
m.destroy();
|
|
190
|
+
} catch {
|
|
191
|
+
/* best effort */
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Canvas-realm frame path. Awareness frames are unchanged (cursors/presence
|
|
198
|
+
* are ephemeral and already server-attributed). Sync frames are gated.
|
|
199
|
+
*/
|
|
200
|
+
function receiveGated(conn: RoomConn, payload: Uint8Array): void {
|
|
201
|
+
if (readMessageType(payload) !== MESSAGE_SYNC) {
|
|
202
|
+
const reply = handleMessage(payload, doc, awareness, conn);
|
|
203
|
+
if (reply) conn.send(reply);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const m = ensureMirror();
|
|
208
|
+
probeRoots = null;
|
|
209
|
+
let probeThrew = false;
|
|
210
|
+
try {
|
|
211
|
+
applySyncMessage(payload, m, GATE_PROBE_ORIGIN);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
probeThrew = true;
|
|
214
|
+
console.error(`[collab/${slug}] origin gate: probe apply failed:`, err);
|
|
215
|
+
}
|
|
216
|
+
// Fail closed on anything we could not positively classify — a probe that
|
|
217
|
+
// threw, or a root type we could not resolve to a name.
|
|
218
|
+
const roots = probeRoots ?? new Set<string>();
|
|
219
|
+
const refused = [...roots].filter((r) => isBodyLane(r) || r === '<unresolved>');
|
|
220
|
+
if (probeThrew || refused.length > 0) {
|
|
221
|
+
gateRefusalCount += 1;
|
|
222
|
+
discardMirror();
|
|
223
|
+
console.warn(
|
|
224
|
+
`[collab/${slug}] origin gate REFUSED a canvas-realm sync frame ` +
|
|
225
|
+
`(lanes: ${refused.join(', ') || 'unclassifiable'}). Untrusted canvas script may ` +
|
|
226
|
+
`not write canvas source — see DDR-122 follow-up / collab/origins.ts.`
|
|
227
|
+
);
|
|
228
|
+
// Re-assert server truth to that peer so it converges on the authoritative
|
|
229
|
+
// state instead of silently diverging. Their locally-authored body items
|
|
230
|
+
// stay in their own realm (they made them) but never reach disk or a peer.
|
|
231
|
+
try {
|
|
232
|
+
conn.send(encodeSyncUpdate(Y.encodeStateAsUpdate(doc)));
|
|
233
|
+
} catch {
|
|
234
|
+
/* close handler will clean up */
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const reply = applySyncMessage(payload, doc, conn);
|
|
240
|
+
if (reply) conn.send(reply);
|
|
241
|
+
}
|
|
242
|
+
|
|
136
243
|
// Y.Doc update -> broadcast to all peers + schedule debounced flush.
|
|
137
244
|
doc.on('update', (update: Uint8Array, origin: unknown) => {
|
|
138
245
|
if (destroyed) return;
|
|
246
|
+
// Keep the gate mirror in lockstep with the room doc, so the next probe is
|
|
247
|
+
// evaluated against current truth. Idempotent — a re-applied update is a
|
|
248
|
+
// no-op in yjs, which is what makes the "probe then apply for real" double
|
|
249
|
+
// application safe.
|
|
250
|
+
if (mirror) {
|
|
251
|
+
try {
|
|
252
|
+
Y.applyUpdate(mirror, update, GATE_MIRROR_SYNC);
|
|
253
|
+
} catch {
|
|
254
|
+
discardMirror();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
139
257
|
// Don't echo back to the origin — they already have it; reduces noise.
|
|
140
258
|
const except =
|
|
141
259
|
origin && typeof origin === 'object' && 'id' in origin ? (origin as RoomConn) : undefined;
|
|
@@ -197,6 +315,10 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
197
315
|
}
|
|
198
316
|
|
|
199
317
|
function receive(conn: RoomConn, payload: Uint8Array): void {
|
|
318
|
+
if (conn.realm === 'canvas') {
|
|
319
|
+
receiveGated(conn, payload);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
200
322
|
const reply = handleMessage(payload, doc, awareness, conn);
|
|
201
323
|
if (reply) conn.send(reply);
|
|
202
324
|
}
|
|
@@ -235,6 +357,7 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
235
357
|
}
|
|
236
358
|
if (dirty) await flush();
|
|
237
359
|
conns.clear();
|
|
360
|
+
discardMirror();
|
|
238
361
|
awareness.destroy();
|
|
239
362
|
doc.destroy();
|
|
240
363
|
}
|
|
@@ -250,6 +373,7 @@ export function createRoom(slug: string, callbacks: RoomCallbacks): Room {
|
|
|
250
373
|
flush,
|
|
251
374
|
destroy,
|
|
252
375
|
size: () => conns.size,
|
|
376
|
+
gateRefusals: () => gateRefusalCount,
|
|
253
377
|
};
|
|
254
378
|
}
|
|
255
379
|
|
package/apps/studio/context.ts
CHANGED
|
@@ -38,6 +38,15 @@ export interface LinkedHub {
|
|
|
38
38
|
* preserved. Only for hubs you operate or fully trust.
|
|
39
39
|
*/
|
|
40
40
|
syncTsx?: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* DDR-192 §5 — stable workspace identity for the hub document namespace
|
|
43
|
+
* (`ws/<workspace-id>/<branch>/<slug>`). Authoritative when present; the
|
|
44
|
+
* cloud control plane sets it at provisioning time. Absent, the sync runtime
|
|
45
|
+
* derives it from the git `origin` remote — and if there is no origin either,
|
|
46
|
+
* stays on legacy flat slugs rather than inventing a per-machine id that
|
|
47
|
+
* would split peers of the same project apart. See sync/doc-name.ts.
|
|
48
|
+
*/
|
|
49
|
+
workspaceId?: string;
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
export interface DevServerConfig {
|