@1agh/maude 0.48.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 (33) hide show
  1. package/apps/studio/api.ts +18 -2
  2. package/apps/studio/assets-s3.ts +291 -0
  3. package/apps/studio/collab/origins.ts +150 -0
  4. package/apps/studio/collab/protocol.ts +36 -9
  5. package/apps/studio/collab/room.ts +124 -0
  6. package/apps/studio/context.ts +9 -0
  7. package/apps/studio/dist/client.bundle.js +1 -1
  8. package/apps/studio/dist/comment-mount.js +2 -2
  9. package/apps/studio/http.ts +45 -0
  10. package/apps/studio/server.ts +75 -2
  11. package/apps/studio/sync/autocommit.ts +299 -0
  12. package/apps/studio/sync/doc-name.ts +228 -0
  13. package/apps/studio/sync/index.ts +95 -1
  14. package/apps/studio/sync/workspace-signin.ts +301 -0
  15. package/apps/studio/test/assets-s3.test.ts +249 -0
  16. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  17. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  18. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  19. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  20. package/apps/studio/test/workspace-containment.test.ts +258 -0
  21. package/apps/studio/test/workspace-signin.test.ts +270 -0
  22. package/apps/studio/use-collab.tsx +28 -1
  23. package/apps/studio/workspace-mode.ts +210 -0
  24. package/apps/studio/ws.ts +11 -2
  25. package/cli/commands/hub-workspace.mjs +341 -0
  26. package/cli/commands/hub.mjs +325 -3
  27. package/cli/commands/hub.test.mjs +14 -1
  28. package/cli/lib/cell-plan.mjs +302 -0
  29. package/cli/lib/cell-plan.test.mjs +225 -0
  30. package/cli/lib/gitignore-block.mjs +12 -1
  31. package/cli/lib/workspace-plan.mjs +422 -0
  32. package/cli/lib/workspace-plan.test.mjs +223 -0
  33. package/package.json +8 -8
@@ -5,7 +5,7 @@ import crypto from 'node:crypto';
5
5
  import type { Dirent } from 'node:fs';
6
6
  import { lstat, mkdir, readdir, readFile, rename, rm, stat as statp } from 'node:fs/promises';
7
7
  import path from 'node:path';
8
-
8
+ import { createAssetMirror, s3ConfigFromEnv } from './assets-s3.ts';
9
9
  import { renderBriefBoard, validateCanvasName } from './canvas-create.ts';
10
10
  import {
11
11
  type AssembleClip,
@@ -1646,6 +1646,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1646
1646
  // user input, but a poisoned designRoot must still not escape).
1647
1647
  // Running total of bytes this server instance has actually written (post-dedupe).
1648
1648
  let assetBytesWritten = 0;
1649
+ // S3/R2 asset lane (Cloud Phase 3 Task 2). Unconfigured by default — a local
1650
+ // project and a single-box self-hoster both work with no bucket at all.
1651
+ const assetMirror = createAssetMirror(s3ConfigFromEnv());
1652
+ if (assetMirror.configured) {
1653
+ console.log(`[assets] mirroring new assets to ${assetMirror.describe}`);
1654
+ }
1649
1655
 
1650
1656
  /**
1651
1657
  * DDR-148 — the streaming write path behind `POST /_api/asset`. Reads the
@@ -1777,7 +1783,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
1777
1783
  }
1778
1784
  await rename(tmpAbs, fileAbs);
1779
1785
  assetBytesWritten += total;
1780
- return { ok: true, path: `assets/${name}` };
1786
+ const rel = `assets/${name}`;
1787
+ // S3/R2 lane (Cloud Phase 3) — mirror the bytes so a second machine can
1788
+ // resolve them without the file riding git. Deliberately awaited but
1789
+ // never able to fail the save: the asset is already on disk, the mirror
1790
+ // is the redundant copy, and `maude hub asset-check` reconciles a miss.
1791
+ // Only a genuinely NEW file reaches here, so this is not re-uploading on
1792
+ // every dedupe hit.
1793
+ if (assetMirror.configured) {
1794
+ void assetMirror.push(rel, new Uint8Array(await Bun.file(fileAbs).arrayBuffer()));
1795
+ }
1796
+ return { ok: true, path: rel };
1781
1797
  } catch (err) {
1782
1798
  await cleanup();
1783
1799
  return { ok: false, status: 500, error: err instanceof Error ? err.message : 'write failed' };
@@ -0,0 +1,291 @@
1
+ // S3/R2 asset lane — Cloud Phase 3 Task 2.
2
+ //
3
+ // THE PROBLEM. Binary media currently rides git. A 60 MB video in a design repo
4
+ // is 60 MB in every clone, forever, and git's delta compression does nothing for
5
+ // it. DDR-148's line that "video rides git and hub sync" describes an intent the
6
+ // code never made true cross-machine; this lane is what makes heavy media
7
+ // actually reach a second machine without bloating history.
8
+ //
9
+ // THE SHAPE. Assets stay CONTENT-ADDRESSED exactly as they are today —
10
+ // `assets/<sha8>.<ext>`. That single property is what makes every operation
11
+ // here safe:
12
+ //
13
+ // • push is idempotent — the same bytes produce the same key, so a re-upload
14
+ // is a no-op rather than a duplicate;
15
+ // • there is no invalidation problem — a key's content never changes, so a
16
+ // cached copy is never stale;
17
+ // • pull is verifiable — the bytes must hash back to the key they came from,
18
+ // which is what lets us accept them from a semi-trusted hub (DDR-054).
19
+ //
20
+ // NEVER GARBAGE-COLLECTED. A canvas in git history can reference an asset that
21
+ // no current canvas does, so "unreferenced" never means "unreachable". Bucket
22
+ // lifecycle/expiry rules MUST be off for the `assets/` prefix — an expired
23
+ // object is a permanently broken canvas with no recovery path.
24
+ //
25
+ // NO PRESIGNED URLS IN A CANVAS. The canvas origin's CSP is `img-src 'self'`
26
+ // (DDR-063/DDR-054) and stays that way: media is fetched through the hub's
27
+ // authenticated proxy and served same-origin. A presigned URL would also be a
28
+ // bearer credential embedded in tenant-authored content — exactly the thing the
29
+ // canvas must never hold.
30
+
31
+ import { createHash, createHmac } from 'node:crypto';
32
+
33
+ // ---------------------------------------------------------------- SigV4
34
+
35
+ const sha256Hex = (data: string | Uint8Array): string =>
36
+ createHash('sha256').update(data).digest('hex');
37
+ const hmac = (key: string | Buffer, data: string): Buffer =>
38
+ createHmac('sha256', key).update(data).digest();
39
+
40
+ export interface S3Config {
41
+ endpoint: string;
42
+ bucket: string;
43
+ accessKeyId: string;
44
+ secretAccessKey: string;
45
+ region: string;
46
+ sessionToken?: string;
47
+ }
48
+
49
+ /**
50
+ * Resolve an S3 target from environment. Returns null when not configured —
51
+ * an unconfigured asset lane is the DEFAULT, not an error: a local project and
52
+ * a self-hoster on a single box both work without a bucket.
53
+ */
54
+ export function s3ConfigFromEnv(env: NodeJS.ProcessEnv = process.env): S3Config | null {
55
+ const endpoint = env.MAUDE_S3_ENDPOINT;
56
+ const bucket = env.MAUDE_S3_BUCKET;
57
+ const accessKeyId = env.MAUDE_S3_ACCESS_KEY_ID;
58
+ const secretAccessKey = env.MAUDE_S3_SECRET_ACCESS_KEY;
59
+ if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) return null;
60
+ return {
61
+ endpoint: endpoint.replace(/\/+$/, ''),
62
+ bucket,
63
+ accessKeyId,
64
+ secretAccessKey,
65
+ region: env.MAUDE_S3_REGION || 'auto',
66
+ ...(env.MAUDE_S3_SESSION_TOKEN ? { sessionToken: env.MAUDE_S3_SESSION_TOKEN } : {}),
67
+ };
68
+ }
69
+
70
+ function encodeSegment(segment: string): string {
71
+ return encodeURIComponent(segment).replace(
72
+ /[!'()*]/g,
73
+ (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`
74
+ );
75
+ }
76
+
77
+ function encodeKey(key: string): string {
78
+ return key.split('/').map(encodeSegment).join('/');
79
+ }
80
+
81
+ export interface SignedRequest {
82
+ url: string;
83
+ headers: Record<string, string>;
84
+ }
85
+
86
+ /**
87
+ * AWS Signature Version 4 for one request.
88
+ *
89
+ * Hand-rolled for the same reason the hub's is (DDR-194 §7): `@aws-sdk/client-s3`
90
+ * is ~20 MB and several hundred packages to perform three HTTP verbs, and this
91
+ * code ships inside a compiled binary users install. Deliberately mirrors
92
+ * `apps/hub/src/s3.mjs` rather than sharing it — the two packages build
93
+ * independently — and `test/assets-s3.test.ts` pins them to the same signature
94
+ * for the same input so they cannot drift.
95
+ */
96
+ export function signRequest(
97
+ cfg: S3Config,
98
+ {
99
+ method,
100
+ key,
101
+ body = null,
102
+ now = new Date(),
103
+ }: { method: string; key: string; body?: Uint8Array | null; now?: Date }
104
+ ): SignedRequest {
105
+ const url = new URL(`${cfg.endpoint}/${cfg.bucket}${key ? `/${encodeKey(key)}` : ''}`);
106
+ const iso = now
107
+ .toISOString()
108
+ .replace(/[-:]/g, '')
109
+ .replace(/\.\d{3}/, '');
110
+ const dateStamp = iso.slice(0, 8);
111
+ const payloadHash = body === null ? sha256Hex('') : sha256Hex(body);
112
+
113
+ const headers: Record<string, string> = {
114
+ host: url.host,
115
+ 'x-amz-content-sha256': payloadHash,
116
+ 'x-amz-date': iso,
117
+ ...(cfg.sessionToken ? { 'x-amz-security-token': cfg.sessionToken } : {}),
118
+ };
119
+ const names = Object.keys(headers).sort();
120
+ const canonicalHeaders = names.map((h) => `${h}:${headers[h]}\n`).join('');
121
+ const signedHeaders = names.join(';');
122
+ const canonicalRequest = [
123
+ method,
124
+ url.pathname,
125
+ '',
126
+ canonicalHeaders,
127
+ signedHeaders,
128
+ payloadHash,
129
+ ].join('\n');
130
+
131
+ const scope = `${dateStamp}/${cfg.region}/s3/aws4_request`;
132
+ const stringToSign = ['AWS4-HMAC-SHA256', iso, scope, sha256Hex(canonicalRequest)].join('\n');
133
+
134
+ let signingKey = hmac(`AWS4${cfg.secretAccessKey}`, dateStamp);
135
+ signingKey = hmac(signingKey, cfg.region);
136
+ signingKey = hmac(signingKey, 's3');
137
+ signingKey = hmac(signingKey, 'aws4_request');
138
+ const signature = createHmac('sha256', signingKey).update(stringToSign).digest('hex');
139
+
140
+ headers.authorization =
141
+ `AWS4-HMAC-SHA256 Credential=${cfg.accessKeyId}/${scope}, ` +
142
+ `SignedHeaders=${signedHeaders}, Signature=${signature}`;
143
+
144
+ return { url: url.toString(), headers };
145
+ }
146
+
147
+ // ------------------------------------------------------------ content address
148
+
149
+ /** The `sha8` in `assets/<sha8>.<ext>` — first 8 hex chars of sha256. */
150
+ export function sha8(bytes: Uint8Array): string {
151
+ return createHash('sha256').update(bytes).digest('hex').slice(0, 8);
152
+ }
153
+
154
+ /**
155
+ * Extract the sha8 from a designRoot-relative asset path, or null when the path
156
+ * is not a content-addressed asset (a legacy or hand-placed file).
157
+ *
158
+ * The suffix after the hash is deliberately permissive, because the real corpus
159
+ * is more varied than `<sha8>.<ext>`:
160
+ *
161
+ * assets/deadbeef.png saveAsset's own output
162
+ * assets/deadbeef.photo.json a sidecar (dotted, multi-part)
163
+ * assets/deadbeef-cloud.mp4 ingested footage, hash + human label
164
+ *
165
+ * A stricter pattern silently classified the last two as "not content
166
+ * addressed", which made `verifyAssetBytes` refuse them — so the mirror would
167
+ * have rejected legitimate assets as unverifiable. Found by running
168
+ * `maude hub asset-check` against this repo's own design root, not by a test:
169
+ * the fixtures all had the tidy shape.
170
+ *
171
+ * What must NOT match is anything where the 8 hex chars are not a complete
172
+ * token — `deadbeef1.png` is a different name, not this asset.
173
+ */
174
+ export function sha8FromAssetPath(rel: string): string | null {
175
+ const m = rel.match(/^assets\/([0-9a-f]{8})(?:[-.][^/]*)?$/);
176
+ return m?.[1] ?? null;
177
+ }
178
+
179
+ /**
180
+ * Verify bytes against the sha8 embedded in the path they were fetched under.
181
+ *
182
+ * This is what makes accepting an asset from a SEMI-TRUSTED hub (DDR-054) safe:
183
+ * the hub can refuse to serve, but it cannot substitute different bytes without
184
+ * the mismatch being detectable. A path with no sha8 (legacy) can't be verified
185
+ * and is treated as unverifiable rather than valid.
186
+ */
187
+ export function verifyAssetBytes(rel: string, bytes: Uint8Array): boolean {
188
+ const expected = sha8FromAssetPath(rel);
189
+ if (!expected) return false;
190
+ return sha8(bytes) === expected;
191
+ }
192
+
193
+ // ------------------------------------------------------------------ the lane
194
+
195
+ export interface AssetMirror {
196
+ readonly configured: boolean;
197
+ readonly describe: string;
198
+ /**
199
+ * Upload one asset. Content-addressed ⇒ idempotent, so a re-push of identical
200
+ * bytes is harmless. Returns false on failure rather than throwing: a failed
201
+ * mirror must NEVER fail the local save (the file is on disk and in git; the
202
+ * bucket is the redundant copy, not the authority).
203
+ */
204
+ push(rel: string, bytes: Uint8Array): Promise<boolean>;
205
+ /** Download one asset, or null when absent. Verifies the content address. */
206
+ pull(rel: string): Promise<Uint8Array | null>;
207
+ /** True when the object exists. Used by the dangling-pointer check. */
208
+ has(rel: string): Promise<boolean>;
209
+ }
210
+
211
+ const NOOP_MIRROR: AssetMirror = {
212
+ configured: false,
213
+ describe: 'none',
214
+ async push() {
215
+ return false;
216
+ },
217
+ async pull() {
218
+ return null;
219
+ },
220
+ async has() {
221
+ return false;
222
+ },
223
+ };
224
+
225
+ export function createAssetMirror(
226
+ cfg: S3Config | null,
227
+ { log = console }: { log?: Pick<Console, 'warn'> } = {}
228
+ ): AssetMirror {
229
+ if (!cfg) return NOOP_MIRROR;
230
+
231
+ const send = (method: string, key: string, body: Uint8Array | null = null) => {
232
+ const { url, headers } = signRequest(cfg, { method, key, body });
233
+ return fetch(url, { method, headers, ...(body === null ? {} : { body }) });
234
+ };
235
+
236
+ return {
237
+ configured: true,
238
+ describe: `s3://${cfg.bucket} @ ${cfg.endpoint}`,
239
+
240
+ async push(rel, bytes) {
241
+ try {
242
+ const res = await send('PUT', rel, bytes);
243
+ if (!res.ok) {
244
+ log.warn(`[assets] mirror PUT ${rel} failed: ${res.status}`);
245
+ return false;
246
+ }
247
+ return true;
248
+ } catch (err) {
249
+ // Offline, DNS, a bad key — all the same answer. The asset is safely on
250
+ // local disk; a later push (or `maude hub asset-check`) reconciles.
251
+ log.warn(`[assets] mirror PUT ${rel} failed: ${(err as Error).message}`);
252
+ return false;
253
+ }
254
+ },
255
+
256
+ async pull(rel) {
257
+ try {
258
+ const res = await send('GET', rel);
259
+ if (res.status === 404) return null;
260
+ if (!res.ok) {
261
+ log.warn(`[assets] mirror GET ${rel} failed: ${res.status}`);
262
+ return null;
263
+ }
264
+ const bytes = new Uint8Array(await res.arrayBuffer());
265
+ if (!verifyAssetBytes(rel, bytes)) {
266
+ // Content address mismatch: either corruption or substitution. Refuse
267
+ // either way — writing these bytes to disk under this name would
268
+ // poison every peer that later mirrors from us.
269
+ log.warn(
270
+ `[assets] REFUSING ${rel}: content does not hash to its own name ` +
271
+ '(corruption or substitution — see DDR-054)'
272
+ );
273
+ return null;
274
+ }
275
+ return bytes;
276
+ } catch (err) {
277
+ log.warn(`[assets] mirror GET ${rel} failed: ${(err as Error).message}`);
278
+ return null;
279
+ }
280
+ },
281
+
282
+ async has(rel) {
283
+ try {
284
+ const res = await send('HEAD', rel);
285
+ return res.ok;
286
+ } catch {
287
+ return false;
288
+ }
289
+ },
290
+ };
291
+ }
@@ -0,0 +1,150 @@
1
+ // Origin gate for canvas-realm Y.Doc ops — the named-but-undone DDR-122
2
+ // follow-up ("Origin-gate canvas-injected doc ops"), landed by Cloud Phase 1
3
+ // Task 3.
4
+ //
5
+ // THE THREAT (DDR-054 F1/F3, restated by DDR-122's security residual): the
6
+ // canvas iframe is untrusted, and same-realm canvas script can reach the live
7
+ // collab Y.Doc via `useCollab().doc`. Nothing stopped it from writing the BODY
8
+ // lanes — `html` / `css` / `meta` / `syncMeta`, i.e. the canvas's own `.tsx`,
9
+ // its sibling `.css`, and the shared `.meta.json` — which the sync agent then
10
+ // materializes to **every peer's disk**. That is "untrusted canvas writes
11
+ // source code to all peers" (stored-XSS + Claude-Code-context-poisoning).
12
+ //
13
+ // THE GATE, dual-lock (DDR-063's canvas-origin split is the seam that makes
14
+ // lock 2 expressible at all):
15
+ //
16
+ // Lock 1 — CLIENT (`use-collab.tsx`): a local doc update that touches a body
17
+ // lane is not broadcast to the server unless its transaction origin
18
+ // is a *trusted sentinel* registered here. Closes the honest path
19
+ // (`useCollab().doc.getText('html').insert(...)`).
20
+ // Lock 2 — SERVER (`collab/room.ts`): an update arriving on a collab socket
21
+ // that was upgraded on the CANVAS origin is validated against a
22
+ // mirror doc before it is allowed to touch the real room doc; a
23
+ // body-lane write is refused outright and never broadcast. Closes
24
+ // the bypass where canvas script skips `use-collab` and opens its
25
+ // own WebSocket to `/_ws/collab/:slug`.
26
+ //
27
+ // Lock 1 alone is defense-in-depth; lock 2 is the actual boundary. Both exist
28
+ // because lock 1 is where the intent is legible and lock 2 is where it is
29
+ // enforceable.
30
+ //
31
+ // WHY A MIRROR DOC AND NOT UPDATE INSPECTION (load-bearing, don't "simplify"):
32
+ // `Y.decodeUpdate` only reveals a struct's parent when the struct has neither a
33
+ // left nor a right origin (yjs writes the parent key *only* in that case). An
34
+ // insert into existing text carries an origin ID instead, so the lane it targets
35
+ // is simply not in the bytes — it is only resolvable against a doc that already
36
+ // holds the referenced items. Hence: apply to a mirror of the room doc, read
37
+ // `transaction.changed`, decide, and only then touch the real doc.
38
+
39
+ import * as Y from 'yjs';
40
+
41
+ /**
42
+ * Lanes that carry the canvas SOURCE (and its sync bookkeeping). Written only
43
+ * by server-side code — `sync/agent.ts`, `sync/codec.ts`, `sync/projection.ts`.
44
+ * No browser-realm code writes these today; the gate keeps it that way.
45
+ */
46
+ const BODY_LANES = new Set<string>(['html', 'css', 'meta', 'syncMeta']);
47
+
48
+ /**
49
+ * Lanes the canvas realm legitimately co-authors — annotations (draw layer),
50
+ * comments, and the presentation channel. Deliberately NOT used as an allowlist
51
+ * for the gate (a new lane must be reasoned about, not silently permitted); it
52
+ * documents intent and backs `isCanvasAuthorableLane` for callers that want the
53
+ * positive form.
54
+ */
55
+ const CANVAS_AUTHORABLE_LANES = new Set<string>(['comments', 'annotations', 'presentation']);
56
+
57
+ /** True when `name` is a source-carrying lane the canvas realm may never write. */
58
+ export function isBodyLane(name: string): boolean {
59
+ return BODY_LANES.has(name);
60
+ }
61
+
62
+ /** True when `name` is a lane the canvas realm co-authors by design. */
63
+ export function isCanvasAuthorableLane(name: string): boolean {
64
+ return CANVAS_AUTHORABLE_LANES.has(name);
65
+ }
66
+
67
+ /** Test/introspection — the frozen lane vocabulary as plain arrays. */
68
+ export function laneVocabulary(): { body: string[]; canvasAuthorable: string[] } {
69
+ return { body: [...BODY_LANES].sort(), canvasAuthorable: [...CANVAS_AUTHORABLE_LANES].sort() };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Trusted origin sentinels
74
+ // ---------------------------------------------------------------------------
75
+
76
+ // A WeakSet, not a marker property: a marker property is forgeable by any
77
+ // same-realm script (`{ maudeTrusted: true }`), a WeakSet membership is not —
78
+ // an attacker would need the sentinel *object itself*, which is only reachable
79
+ // from module scope of the modules that legitimately hold it.
80
+ const TRUSTED_ORIGINS = new WeakSet<object>();
81
+
82
+ /**
83
+ * Register `origin` as a trusted doc-update origin. Returns it, so a sentinel
84
+ * can be declared and marked in one expression. Frozen objects are fine —
85
+ * freezing does not affect WeakSet membership.
86
+ */
87
+ export function markTrustedOrigin<T extends object>(origin: T): T {
88
+ TRUSTED_ORIGINS.add(origin);
89
+ return origin;
90
+ }
91
+
92
+ /** True when a Y transaction origin was registered via `markTrustedOrigin`. */
93
+ export function isTrustedOrigin(origin: unknown): boolean {
94
+ return typeof origin === 'object' && origin !== null && TRUSTED_ORIGINS.has(origin as object);
95
+ }
96
+
97
+ /**
98
+ * The shell's sanctioned edit path — the inspector / shell UI mutating a body
99
+ * lane on behalf of an explicit user gesture. Nothing in the canvas realm can
100
+ * obtain this object; it is exported for the shell modules that need it.
101
+ */
102
+ export const SHELL_EDIT_ORIGIN: object = markTrustedOrigin(
103
+ Object.freeze({ maudeOrigin: 'shell-edit' })
104
+ );
105
+
106
+ /**
107
+ * The local sync agent applying a disk-authoritative body into the doc. Used by
108
+ * server-side code that runs outside the canvas realm entirely; marked here so
109
+ * the client-side lock stays correct if a future build ever colocates them.
110
+ */
111
+ export const SYNC_AGENT_ORIGIN: object = markTrustedOrigin(
112
+ Object.freeze({ maudeOrigin: 'sync-agent' })
113
+ );
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Transaction inspection
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Root shared-type names a transaction touched.
121
+ *
122
+ * `transaction.changed` is keyed by the *concrete* type that changed, which for
123
+ * a nested structure is not the root — so walk `_item.parent` up to the root and
124
+ * resolve its registered name via `Y.findRootTypeKey`. A type that cannot be
125
+ * resolved (detached / mid-destroy) yields the sentinel `'<unresolved>'`, which
126
+ * is deliberately NOT a body lane but also not an authorable one — callers that
127
+ * fail closed should treat an unresolved root as untrusted.
128
+ */
129
+ export function rootTypesTouched(transaction: Y.Transaction): Set<string> {
130
+ const roots = new Set<string>();
131
+ for (const type of transaction.changed.keys()) {
132
+ let t: Y.AbstractType<unknown> = type;
133
+ // biome-ignore lint/suspicious/noExplicitAny: `_item` is yjs-internal but stable API surface for parent walks.
134
+ while ((t as any)._item != null) t = (t as any)._item.parent;
135
+ try {
136
+ roots.add(Y.findRootTypeKey(t));
137
+ } catch {
138
+ roots.add('<unresolved>');
139
+ }
140
+ }
141
+ return roots;
142
+ }
143
+
144
+ /** True when a transaction wrote any source-carrying lane. */
145
+ export function touchesBodyLane(transaction: Y.Transaction): boolean {
146
+ for (const name of rootTypesTouched(transaction)) {
147
+ if (isBodyLane(name)) return true;
148
+ }
149
+ return false;
150
+ }
@@ -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;