@1agh/maude 0.47.0 → 0.49.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 (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -71,6 +71,40 @@ export function encodeAwarenessFrame(
71
71
  return encoding.toUint8Array(encoder);
72
72
  }
73
73
 
74
+ /**
75
+ * Peek the message type of a frame without consuming it. Lets a caller route
76
+ * sync vs awareness before deciding *which doc* to apply the frame to — the
77
+ * origin gate (`collab/origins.ts`) validates sync frames against a mirror doc
78
+ * first, and needs to know a frame is a sync frame to do so.
79
+ */
80
+ export function readMessageType(payload: Uint8Array): number {
81
+ return decoding.readVarUint(decoding.createDecoder(payload));
82
+ }
83
+
84
+ /**
85
+ * Apply one MESSAGE_SYNC frame to `doc` with `origin` as the Y transaction
86
+ * origin, returning the reply frame (sync step 2 / ack) if there is one.
87
+ *
88
+ * Split out of `handleMessage` so the same bytes can be applied to a throwaway
89
+ * mirror doc under a probe origin (the origin gate) before they are allowed
90
+ * anywhere near the real room doc. Non-sync payloads return `null` untouched.
91
+ */
92
+ export function applySyncMessage(
93
+ payload: Uint8Array,
94
+ doc: Y.Doc,
95
+ origin: unknown
96
+ ): Uint8Array | null {
97
+ const decoder = decoding.createDecoder(payload);
98
+ if (decoding.readVarUint(decoder) !== MESSAGE_SYNC) return null;
99
+ const encoder = encoding.createEncoder();
100
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
101
+ // readSyncMessage applies the peer's update to the doc and writes the
102
+ // response (sync step 2 / sync step 2 ack) into encoder.
103
+ syncProtocol.readSyncMessage(decoder, encoder, doc, origin);
104
+ if (encoding.length(encoder) > 1) return encoding.toUint8Array(encoder);
105
+ return null;
106
+ }
107
+
74
108
  /**
75
109
  * Decode and dispatch one incoming binary frame. Returns an optional reply
76
110
  * frame the caller MUST send back to the originating peer (sync step 2 from
@@ -89,15 +123,8 @@ export function handleMessage(
89
123
  const messageType = decoding.readVarUint(decoder);
90
124
 
91
125
  switch (messageType) {
92
- case MESSAGE_SYNC: {
93
- const encoder = encoding.createEncoder();
94
- encoding.writeVarUint(encoder, MESSAGE_SYNC);
95
- // readSyncMessage applies the peer's update to the doc and writes the
96
- // response (sync step 2 / sync step 2 ack) into encoder.
97
- syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
98
- if (encoding.length(encoder) > 1) return encoding.toUint8Array(encoder);
99
- return null;
100
- }
126
+ case MESSAGE_SYNC:
127
+ return applySyncMessage(payload, doc, conn);
101
128
  case MESSAGE_AWARENESS: {
102
129
  awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn);
103
130
  return null;
@@ -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
 
@@ -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 {