@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
@@ -0,0 +1,249 @@
1
+ // Cloud Phase 3 Task 2 — the S3/R2 asset lane.
2
+ //
3
+ // Exercised against a live in-process S3-shaped server rather than MinIO: the
4
+ // properties worth proving are content-addressing, verification of bytes
5
+ // received from a semi-trusted hub, and never failing a local save because a
6
+ // bucket was unreachable. None of those need a storage vendor.
7
+
8
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
9
+
10
+ import { createServer, type Server } from 'node:http';
11
+ import type { AddressInfo } from 'node:net';
12
+ // The HUB's independent SigV4 implementation — imported to pin the two against
13
+ // each other, exactly as the doc-namespace grammars are pinned.
14
+ import { signRequest as hubSignRequest } from '../../hub/src/s3.mjs';
15
+ import {
16
+ createAssetMirror,
17
+ type S3Config,
18
+ s3ConfigFromEnv,
19
+ sha8,
20
+ sha8FromAssetPath,
21
+ signRequest,
22
+ verifyAssetBytes,
23
+ } from '../assets-s3.ts';
24
+
25
+ let server: Server;
26
+ let store: Map<string, Uint8Array>;
27
+ let seenAuth: string[];
28
+ let cfg: S3Config;
29
+
30
+ beforeAll(async () => {
31
+ store = new Map();
32
+ seenAuth = [];
33
+ server = createServer((req, res) => {
34
+ seenAuth.push(req.headers.authorization ?? '');
35
+ const key = decodeURIComponent(
36
+ new URL(req.url ?? '/', 'http://x').pathname.replace('/maude-assets/', '')
37
+ );
38
+ if (req.method === 'PUT') {
39
+ const chunks: Buffer[] = [];
40
+ req.on('data', (c) => chunks.push(c));
41
+ req.on('end', () => {
42
+ store.set(key, new Uint8Array(Buffer.concat(chunks)));
43
+ res.writeHead(200).end();
44
+ });
45
+ return;
46
+ }
47
+ if (req.method === 'HEAD') {
48
+ res.writeHead(store.has(key) ? 200 : 404).end();
49
+ return;
50
+ }
51
+ if (req.method === 'GET') {
52
+ const body = store.get(key);
53
+ if (!body) {
54
+ res.writeHead(404).end();
55
+ return;
56
+ }
57
+ res.writeHead(200).end(Buffer.from(body));
58
+ return;
59
+ }
60
+ res.writeHead(405).end();
61
+ });
62
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
63
+ cfg = {
64
+ endpoint: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
65
+ bucket: 'maude-assets',
66
+ accessKeyId: 'AKIAEXAMPLE',
67
+ secretAccessKey: 'secret',
68
+ region: 'auto',
69
+ };
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await new Promise<void>((resolve) => server.close(() => resolve()));
74
+ });
75
+
76
+ const bytesFor = (s: string) => new TextEncoder().encode(s);
77
+ const assetPath = (b: Uint8Array, ext = 'png') => `assets/${sha8(b)}.${ext}`;
78
+
79
+ describe('content addressing', () => {
80
+ test('sha8 is stable and differs for different bytes', () => {
81
+ const a = bytesFor('hello');
82
+ expect(sha8(a)).toBe(sha8(new Uint8Array(a)));
83
+ expect(sha8(a)).not.toBe(sha8(bytesFor('hellp')));
84
+ expect(sha8(a)).toMatch(/^[0-9a-f]{8}$/);
85
+ });
86
+
87
+ test('sha8FromAssetPath accepts content-addressed paths and refuses others', () => {
88
+ expect(sha8FromAssetPath('assets/deadbeef.png')).toBe('deadbeef');
89
+ expect(sha8FromAssetPath('assets/deadbeef')).toBe('deadbeef');
90
+ expect(sha8FromAssetPath('assets/deadbeef.mp4')).toBe('deadbeef');
91
+ // The shapes the REAL corpus contains beyond saveAsset's own output — a
92
+ // dotted sidecar and hash+label ingested footage. A stricter pattern
93
+ // classified these as "not content addressed", which made the mirror
94
+ // refuse legitimate assets as unverifiable.
95
+ expect(sha8FromAssetPath('assets/deadbeef.photo.json')).toBe('deadbeef');
96
+ expect(sha8FromAssetPath('assets/deadbeef.footage.json')).toBe('deadbeef');
97
+ expect(sha8FromAssetPath('assets/deadbeef-cloud.mp4')).toBe('deadbeef');
98
+ expect(sha8FromAssetPath('assets/deadbeef-cloud.srt')).toBe('deadbeef');
99
+ // Legacy / hand-placed / traversal shapes are not content-addressed. The
100
+ // 8 hex chars must be a COMPLETE token — `deadbeef1` is a different name.
101
+ expect(sha8FromAssetPath('assets/my-photo.png')).toBe(null);
102
+ expect(sha8FromAssetPath('assets/DEADBEEF.png')).toBe(null);
103
+ expect(sha8FromAssetPath('assets/deadbeef1.png')).toBe(null);
104
+ expect(sha8FromAssetPath('assets/deadbeefcafe.png')).toBe(null);
105
+ expect(sha8FromAssetPath('assets/sub/deadbeef.png')).toBe(null);
106
+ expect(sha8FromAssetPath('../assets/deadbeef.png')).toBe(null);
107
+ expect(sha8FromAssetPath('')).toBe(null);
108
+ });
109
+
110
+ test('verifyAssetBytes is the check that makes a semi-trusted hub usable', () => {
111
+ const bytes = bytesFor('the real image');
112
+ const path = assetPath(bytes);
113
+ expect(verifyAssetBytes(path, bytes)).toBe(true);
114
+ // ...including the labelled and sidecar shapes.
115
+ expect(verifyAssetBytes(`assets/${sha8(bytes)}-cloud.mp4`, bytes)).toBe(true);
116
+ expect(verifyAssetBytes(`assets/${sha8(bytes)}.photo.json`, bytes)).toBe(true);
117
+ // Substituted content under the same name — the attack DDR-054 describes.
118
+ expect(verifyAssetBytes(path, bytesFor('malicious replacement'))).toBe(false);
119
+ // A path with no content address cannot be verified, so it is not trusted.
120
+ expect(verifyAssetBytes('assets/logo.png', bytes)).toBe(false);
121
+ });
122
+ });
123
+
124
+ describe('config', () => {
125
+ test('an unconfigured lane is the default, not an error', () => {
126
+ expect(s3ConfigFromEnv({})).toBe(null);
127
+ // Partial config must not half-enable the lane.
128
+ expect(s3ConfigFromEnv({ MAUDE_S3_BUCKET: 'b', MAUDE_S3_ENDPOINT: 'e' })).toBe(null);
129
+ const mirror = createAssetMirror(null);
130
+ expect(mirror.configured).toBe(false);
131
+ });
132
+
133
+ test('a full env set configures it, trailing slash trimmed, region defaulted', () => {
134
+ const resolved = s3ConfigFromEnv({
135
+ MAUDE_S3_ENDPOINT: 'https://acct.r2.cloudflarestorage.com/',
136
+ MAUDE_S3_BUCKET: 'maude-assets',
137
+ MAUDE_S3_ACCESS_KEY_ID: 'AKIA',
138
+ MAUDE_S3_SECRET_ACCESS_KEY: 'secret',
139
+ });
140
+ expect(resolved?.endpoint).toBe('https://acct.r2.cloudflarestorage.com');
141
+ expect(resolved?.region).toBe('auto');
142
+ });
143
+ });
144
+
145
+ describe('SigV4 — pinned against the hub implementation', () => {
146
+ test('studio and hub sign the same request identically', () => {
147
+ // Two independent implementations in two independently-built packages. If
148
+ // they drift, one of them is wrong against real R2 and nobody would notice
149
+ // until a deploy.
150
+ const shared = {
151
+ endpoint: 'https://acct.r2.cloudflarestorage.com',
152
+ bucket: 'maude-assets',
153
+ accessKeyId: 'AKIAEXAMPLE',
154
+ secretAccessKey: 'wJalrXUtnFEMI',
155
+ region: 'auto',
156
+ };
157
+ const now = new Date('2026-07-28T20:30:00Z');
158
+ for (const key of ['assets/deadbeef.png', 'assets/a b+c!.png', 'assets/ünïcode.png']) {
159
+ for (const body of [null, bytesFor('payload')]) {
160
+ const mine = signRequest(shared, { method: 'PUT', key, body, now });
161
+ const theirs = hubSignRequest(shared, { method: 'PUT', key, body, now });
162
+ expect(mine.headers.authorization).toBe(theirs.headers.authorization);
163
+ expect(mine.url).toBe(theirs.url);
164
+ }
165
+ }
166
+ });
167
+
168
+ test('the payload is signed, so different bytes give a different signature', () => {
169
+ const now = new Date('2026-07-28T20:30:00Z');
170
+ const a = signRequest(cfg, { method: 'PUT', key: 'assets/x.png', body: bytesFor('a'), now });
171
+ const b = signRequest(cfg, { method: 'PUT', key: 'assets/x.png', body: bytesFor('b'), now });
172
+ expect(a.headers.authorization).not.toBe(b.headers.authorization);
173
+ });
174
+ });
175
+
176
+ describe('the mirror, against a live S3-shaped server', () => {
177
+ test('push then pull round-trips, and every request is signed', async () => {
178
+ const mirror = createAssetMirror(cfg);
179
+ const bytes = bytesFor('a small png, pretend');
180
+ const rel = assetPath(bytes);
181
+
182
+ expect(await mirror.push(rel, bytes)).toBe(true);
183
+ expect(await mirror.has(rel)).toBe(true);
184
+ const pulled = await mirror.pull(rel);
185
+ expect(pulled).not.toBe(null);
186
+ expect([...(pulled ?? [])]).toEqual([...bytes]);
187
+
188
+ expect(seenAuth.length).toBeGreaterThan(0);
189
+ expect(seenAuth.every((a) => a.startsWith('AWS4-HMAC-SHA256 Credential='))).toBe(true);
190
+ });
191
+
192
+ test('push is idempotent — content addressing means a re-push is a no-op', async () => {
193
+ const mirror = createAssetMirror(cfg);
194
+ const bytes = bytesFor('idempotent content');
195
+ const rel = assetPath(bytes);
196
+ expect(await mirror.push(rel, bytes)).toBe(true);
197
+ const sizeAfterFirst = store.size;
198
+ expect(await mirror.push(rel, bytes)).toBe(true);
199
+ expect(store.size).toBe(sizeAfterFirst);
200
+ });
201
+
202
+ test('a missing object pulls as null, not an error', async () => {
203
+ const mirror = createAssetMirror(cfg);
204
+ expect(await mirror.pull('assets/00000000.png')).toBe(null);
205
+ expect(await mirror.has('assets/00000000.png')).toBe(false);
206
+ });
207
+
208
+ test('SUBSTITUTED bytes are REFUSED — the hub cannot swap an asset', async () => {
209
+ // Plant content under a name it does not hash to, exactly as a compromised
210
+ // or malicious hub would. The pull must refuse rather than write it to disk,
211
+ // where it would poison every peer that later mirrors from us.
212
+ const honest = bytesFor('the real asset');
213
+ const rel = assetPath(honest);
214
+ store.set(rel, bytesFor('malicious replacement of the same length!!'));
215
+
216
+ const warnings: string[] = [];
217
+ const mirror = createAssetMirror(cfg, { log: { warn: (m: string) => warnings.push(m) } });
218
+ expect(await mirror.pull(rel)).toBe(null);
219
+ expect(warnings.join(' ')).toMatch(/does not hash to its own name/);
220
+ });
221
+
222
+ test('an unreachable bucket NEVER fails the caller — it returns false', async () => {
223
+ // The local file is already on disk and in git; the bucket is the redundant
224
+ // copy. Throwing here would turn a network blip into a failed save.
225
+ const dead = createAssetMirror(
226
+ { ...cfg, endpoint: 'http://127.0.0.1:1' },
227
+ { log: { warn: () => {} } }
228
+ );
229
+ expect(await dead.push('assets/deadbeef.png', bytesFor('x'))).toBe(false);
230
+ expect(await dead.pull('assets/deadbeef.png')).toBe(null);
231
+ expect(await dead.has('assets/deadbeef.png')).toBe(false);
232
+ });
233
+
234
+ test('a non-2xx response is handled like a miss, not a crash', async () => {
235
+ const mirror = createAssetMirror(
236
+ { ...cfg, bucket: 'wrong-bucket' },
237
+ { log: { warn: () => {} } }
238
+ );
239
+ expect(await mirror.pull('assets/deadbeef.png')).toBe(null);
240
+ });
241
+
242
+ test('the unconfigured mirror answers safely for every verb', async () => {
243
+ const mirror = createAssetMirror(null);
244
+ expect(await mirror.push('assets/deadbeef.png', bytesFor('x'))).toBe(false);
245
+ expect(await mirror.pull('assets/deadbeef.png')).toBe(null);
246
+ expect(await mirror.has('assets/deadbeef.png')).toBe(false);
247
+ expect(mirror.describe).toBe('none');
248
+ });
249
+ });
@@ -62,6 +62,13 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
62
62
  for (const p of [
63
63
  '/_config',
64
64
  '/_sync-status',
65
+ // Cloud Phase 3 Task 3 — the sign-in handler RECEIVES A PASSWORD, and
66
+ // the disclosure route is main-origin UI chrome. Both are absent from
67
+ // CANVAS_SAFE_API and from startCanvasServer's route map, so a GET
68
+ // 403s at the gate rather than 405-ing from the handler — which is what
69
+ // proves the route is unreachable on this origin at all.
70
+ '/_api/workspace/sign-in',
71
+ '/_api/workspace/disclosure',
65
72
  '/_api/export',
66
73
  // feature-background-export-notification-center — the background job
67
74
  // routes carry the same privileged data class as /_api/export (job
@@ -0,0 +1,323 @@
1
+ // Unit: the DDR-122 follow-up origin gate — untrusted canvas-realm doc ops may
2
+ // never write the canvas SOURCE lanes (html / css / meta / syncMeta), while the
3
+ // lanes the canvas legitimately co-authors (comments / annotations /
4
+ // presentation) keep working for same-machine boards.
5
+ //
6
+ // Cloud Phase 1 Task 3. See apps/studio/collab/origins.ts for the threat model
7
+ // and for why the gate needs a mirror doc rather than update-byte inspection.
8
+
9
+ import { describe, expect, test } from 'bun:test';
10
+
11
+ import * as encoding from 'lib0/encoding';
12
+ import { writeUpdate } from 'y-protocols/sync';
13
+ import * as Y from 'yjs';
14
+
15
+ import {
16
+ isBodyLane,
17
+ isCanvasAuthorableLane,
18
+ isTrustedOrigin,
19
+ laneVocabulary,
20
+ markTrustedOrigin,
21
+ rootTypesTouched,
22
+ SHELL_EDIT_ORIGIN,
23
+ touchesBodyLane,
24
+ } from '../collab/origins.ts';
25
+ import { MESSAGE_SYNC } from '../collab/protocol.ts';
26
+ import { createRoom, type RoomCallbacks, type RoomConn } from '../collab/room.ts';
27
+
28
+ const MESSAGE_SYNC_UPDATE = 2; // y-protocols/sync messageYjsUpdate
29
+
30
+ function makeConn(id: string, realm?: 'main' | 'canvas'): RoomConn & { recv: Uint8Array[] } {
31
+ const recv: Uint8Array[] = [];
32
+ return {
33
+ id,
34
+ ...(realm ? { realm } : {}),
35
+ send(payload: Uint8Array) {
36
+ recv.push(payload);
37
+ },
38
+ recv,
39
+ };
40
+ }
41
+
42
+ function makeCallbacks(): RoomCallbacks {
43
+ return {
44
+ seed() {
45
+ /* nothing on disk */
46
+ },
47
+ persistJson() {
48
+ /* no-op */
49
+ },
50
+ persistBinary() {
51
+ /* no-op */
52
+ },
53
+ };
54
+ }
55
+
56
+ /** Frame a raw Y update as a y-websocket MESSAGE_SYNC / update frame. */
57
+ function syncUpdateFrame(update: Uint8Array): Uint8Array {
58
+ const encoder = encoding.createEncoder();
59
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
60
+ writeUpdate(encoder, update);
61
+ return encoding.toUint8Array(encoder);
62
+ }
63
+
64
+ /**
65
+ * Build the frame a malicious (or merely careless) canvas peer would send: it
66
+ * mirrors the room's current state, mutates locally, and ships the delta —
67
+ * exactly what `use-collab.tsx` does, minus lock 1.
68
+ */
69
+ function peerDelta(room: { doc: Y.Doc }, mutate: (doc: Y.Doc) => void): Uint8Array {
70
+ const peer = new Y.Doc();
71
+ Y.applyUpdate(peer, Y.encodeStateAsUpdate(room.doc));
72
+ const before = Y.encodeStateVector(peer);
73
+ mutate(peer);
74
+ return Y.encodeStateAsUpdate(peer, before);
75
+ }
76
+
77
+ describe('origins — lane vocabulary + trusted sentinels', () => {
78
+ test('body lanes and canvas-authorable lanes are disjoint and complete', () => {
79
+ const { body, canvasAuthorable } = laneVocabulary();
80
+ expect(body).toEqual(['css', 'html', 'meta', 'syncMeta']);
81
+ expect(canvasAuthorable).toEqual(['annotations', 'comments', 'presentation']);
82
+ for (const lane of body) {
83
+ expect(isBodyLane(lane)).toBe(true);
84
+ expect(isCanvasAuthorableLane(lane)).toBe(false);
85
+ }
86
+ for (const lane of canvasAuthorable) {
87
+ expect(isBodyLane(lane)).toBe(false);
88
+ expect(isCanvasAuthorableLane(lane)).toBe(true);
89
+ }
90
+ });
91
+
92
+ test('a forged origin object is NOT trusted; only the real sentinel is', () => {
93
+ expect(isTrustedOrigin(SHELL_EDIT_ORIGIN)).toBe(true);
94
+ // The exact shape of the sentinel, rebuilt by an attacker who read the source.
95
+ expect(isTrustedOrigin({ maudeOrigin: 'shell-edit' })).toBe(false);
96
+ expect(isTrustedOrigin('shell-edit')).toBe(false);
97
+ expect(isTrustedOrigin(null)).toBe(false);
98
+ expect(isTrustedOrigin(undefined)).toBe(false);
99
+ });
100
+
101
+ test('markTrustedOrigin registers a frozen object and returns it', () => {
102
+ const sentinel = markTrustedOrigin(Object.freeze({ maudeOrigin: 'test' }));
103
+ expect(isTrustedOrigin(sentinel)).toBe(true);
104
+ });
105
+
106
+ test('rootTypesTouched resolves nested types up to their root', () => {
107
+ const doc = new Y.Doc();
108
+ const seen: Set<string>[] = [];
109
+ doc.on('afterTransaction', (tr: Y.Transaction) => {
110
+ if (tr.origin === 'probe') seen.push(rootTypesTouched(tr));
111
+ });
112
+ doc.transact(() => {
113
+ const nested = new Y.Map<string>();
114
+ doc.getArray<Y.Map<string>>('comments').push([nested]);
115
+ nested.set('text', 'hi');
116
+ }, 'probe');
117
+ expect(seen.length).toBe(1);
118
+ expect([...(seen[0] ?? [])]).toEqual(['comments']);
119
+ });
120
+
121
+ test('touchesBodyLane discriminates body writes from comment writes', () => {
122
+ const doc = new Y.Doc();
123
+ const verdicts: boolean[] = [];
124
+ doc.on('afterTransaction', (tr: Y.Transaction) => {
125
+ if (tr.origin === 'probe') verdicts.push(touchesBodyLane(tr));
126
+ });
127
+ doc.transact(() => doc.getArray('comments').push([{ id: 'c1' }]), 'probe');
128
+ doc.transact(() => doc.getText('html').insert(0, '<b>x</b>'), 'probe');
129
+ // An insert into EXISTING text — the case whose target lane is absent from
130
+ // the update bytes (origins.ts § "why a mirror doc").
131
+ doc.transact(() => doc.getText('html').insert(3, 'evil'), 'probe');
132
+ expect(verdicts).toEqual([false, true, true]);
133
+ });
134
+ });
135
+
136
+ describe('room origin gate — canvas realm', () => {
137
+ test('REJECTS a canvas-realm write to the body lane; doc untouched, not broadcast', async () => {
138
+ const room = createRoom('ui-screen', makeCallbacks());
139
+ const shell = makeConn('shell', 'main');
140
+ const canvas = makeConn('canvas', 'canvas');
141
+ await room.connect(shell);
142
+ await room.connect(canvas);
143
+ shell.recv.length = 0;
144
+
145
+ // Seed a body through the trusted path so the attack is an insert into
146
+ // EXISTING text — the case update-byte inspection cannot classify.
147
+ room.doc.transact(() => room.doc.getText('html').insert(0, '<main>ok</main>'), 'sync-agent');
148
+ shell.recv.length = 0;
149
+ canvas.recv.length = 0;
150
+
151
+ const evil = peerDelta(room, (d) => d.getText('html').insert(6, 'PWNED'));
152
+ room.receive(canvas, syncUpdateFrame(evil));
153
+
154
+ expect(room.doc.getText('html').toString()).toBe('<main>ok</main>');
155
+ expect(room.gateRefusals()).toBe(1);
156
+ // Nothing reached the other peer.
157
+ expect(shell.recv.length).toBe(0);
158
+ // The refused peer got a corrective full-state frame instead.
159
+ expect(canvas.recv.length).toBe(1);
160
+ await room.destroy();
161
+ });
162
+
163
+ test('REJECTS canvas-realm writes to css, meta and syncMeta too', async () => {
164
+ for (const [lane, mutate] of [
165
+ ['css', (d: Y.Doc) => d.getText('css').insert(0, '.evil{}')],
166
+ ['meta', (d: Y.Doc) => d.getText('meta').insert(0, '{"evil":1}')],
167
+ ['syncMeta', (d: Y.Doc) => d.getMap('syncMeta').set('bodyEditAt', 1)],
168
+ ] as const) {
169
+ const room = createRoom(`lane-${lane}`, makeCallbacks());
170
+ const canvas = makeConn('canvas', 'canvas');
171
+ await room.connect(canvas);
172
+ room.receive(canvas, syncUpdateFrame(peerDelta(room, mutate)));
173
+ expect(room.gateRefusals()).toBe(1);
174
+ expect(Y.encodeStateAsUpdate(room.doc).length).toBe(
175
+ Y.encodeStateAsUpdate(new Y.Doc()).length
176
+ );
177
+ await room.destroy();
178
+ }
179
+ });
180
+
181
+ test('ALLOWS canvas-realm comments + annotations (same-machine boards keep working)', async () => {
182
+ const room = createRoom('board', makeCallbacks());
183
+ const shell = makeConn('shell', 'main');
184
+ const canvas = makeConn('canvas', 'canvas');
185
+ await room.connect(shell);
186
+ await room.connect(canvas);
187
+ shell.recv.length = 0;
188
+
189
+ room.receive(
190
+ canvas,
191
+ syncUpdateFrame(
192
+ peerDelta(room, (d) => {
193
+ d.getArray('comments').push([{ id: 'c1', text: 'from the canvas' }]);
194
+ d.getMap('annotations').set('svg', '<svg><rect/></svg>');
195
+ })
196
+ )
197
+ );
198
+
199
+ expect(room.doc.getArray('comments').toArray()).toEqual([
200
+ { id: 'c1', text: 'from the canvas' },
201
+ ]);
202
+ expect(room.doc.getMap('annotations').get('svg')).toBe('<svg><rect/></svg>');
203
+ expect(room.gateRefusals()).toBe(0);
204
+ // And it DID reach the other peer.
205
+ expect(shell.recv.length).toBeGreaterThan(0);
206
+ await room.destroy();
207
+ });
208
+
209
+ test('a mixed frame is refused WHOLESALE — no partial application of the good half', async () => {
210
+ const room = createRoom('mixed', makeCallbacks());
211
+ const canvas = makeConn('canvas', 'canvas');
212
+ await room.connect(canvas);
213
+
214
+ room.receive(
215
+ canvas,
216
+ syncUpdateFrame(
217
+ peerDelta(room, (d) => {
218
+ d.getArray('comments').push([{ id: 'decoy' }]);
219
+ d.getText('html').insert(0, 'PWNED');
220
+ })
221
+ )
222
+ );
223
+
224
+ expect(room.gateRefusals()).toBe(1);
225
+ expect(room.doc.getArray('comments').length).toBe(0);
226
+ expect(room.doc.getText('html').toString()).toBe('');
227
+ await room.destroy();
228
+ });
229
+
230
+ test('the gate SURVIVES a refusal — the next honest canvas frame still applies', async () => {
231
+ const room = createRoom('recover', makeCallbacks());
232
+ const canvas = makeConn('canvas', 'canvas');
233
+ await room.connect(canvas);
234
+
235
+ room.receive(canvas, syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'x'))));
236
+ expect(room.gateRefusals()).toBe(1);
237
+
238
+ room.receive(
239
+ canvas,
240
+ syncUpdateFrame(peerDelta(room, (d) => d.getArray('comments').push([{ id: 'after' }])))
241
+ );
242
+ expect(room.gateRefusals()).toBe(1);
243
+ expect(room.doc.getArray('comments').toArray()).toEqual([{ id: 'after' }]);
244
+ await room.destroy();
245
+ });
246
+
247
+ test('the MAIN realm is ungated — the shell may still write the body', async () => {
248
+ const room = createRoom('shell-write', makeCallbacks());
249
+ const shell = makeConn('shell', 'main');
250
+ await room.connect(shell);
251
+
252
+ room.receive(shell, syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'ok'))));
253
+
254
+ expect(room.doc.getText('html').toString()).toBe('ok');
255
+ expect(room.gateRefusals()).toBe(0);
256
+ await room.destroy();
257
+ });
258
+
259
+ test('a conn with no realm defaults to trusted (pre-existing callers unchanged)', async () => {
260
+ const room = createRoom('legacy', makeCallbacks());
261
+ const legacy = makeConn('legacy');
262
+ await room.connect(legacy);
263
+
264
+ room.receive(
265
+ legacy,
266
+ syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'ok')))
267
+ );
268
+
269
+ expect(room.doc.getText('html').toString()).toBe('ok');
270
+ expect(room.gateRefusals()).toBe(0);
271
+ await room.destroy();
272
+ });
273
+
274
+ test('awareness frames from the canvas realm pass through the gate untouched', async () => {
275
+ const room = createRoom('presence', makeCallbacks());
276
+ const canvas = makeConn('canvas', 'canvas');
277
+ await room.connect(canvas);
278
+
279
+ // A well-formed awareness frame from a peer's own Awareness instance.
280
+ const peerDoc = new Y.Doc();
281
+ const { Awareness, encodeAwarenessUpdate } = await import('y-protocols/awareness');
282
+ const peerAwareness = new Awareness(peerDoc);
283
+ peerAwareness.setLocalState({ name: 'canvas peer' });
284
+ const encoder = encoding.createEncoder();
285
+ encoding.writeVarUint(encoder, 1 /* MESSAGE_AWARENESS */);
286
+ encoding.writeVarUint8Array(
287
+ encoder,
288
+ encodeAwarenessUpdate(peerAwareness, [peerAwareness.clientID])
289
+ );
290
+ room.receive(canvas, encoding.toUint8Array(encoder));
291
+
292
+ expect(room.awareness.getStates().get(peerAwareness.clientID)).toEqual({ name: 'canvas peer' });
293
+ expect(room.gateRefusals()).toBe(0);
294
+ await room.destroy();
295
+ });
296
+
297
+ test('a malformed sync frame from the canvas realm mutates nothing and leaves the room usable', async () => {
298
+ const room = createRoom('garbage', makeCallbacks());
299
+ const canvas = makeConn('canvas', 'canvas');
300
+ await room.connect(canvas);
301
+
302
+ const encoder = encoding.createEncoder();
303
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
304
+ encoding.writeVarUint(encoder, MESSAGE_SYNC_UPDATE);
305
+ encoding.writeVarUint8Array(encoder, new Uint8Array([0xff, 0xff, 0xff, 0xff]));
306
+ // yjs swallows an undecodable update internally rather than throwing, so
307
+ // this is not a *gate* refusal — it never becomes a transaction at all, on
308
+ // the mirror or on the room doc. Assert the property that matters: nothing
309
+ // applied, no crash, and the gate still works afterwards.
310
+ room.receive(canvas, encoding.toUint8Array(encoder));
311
+
312
+ expect(Y.encodeStateAsUpdate(room.doc).length).toBe(Y.encodeStateAsUpdate(new Y.Doc()).length);
313
+
314
+ room.receive(canvas, syncUpdateFrame(peerDelta(room, (d) => d.getText('html').insert(0, 'x'))));
315
+ expect(room.gateRefusals()).toBe(1);
316
+ room.receive(
317
+ canvas,
318
+ syncUpdateFrame(peerDelta(room, (d) => d.getArray('comments').push([{ id: 'ok' }])))
319
+ );
320
+ expect(room.doc.getArray('comments').toArray()).toEqual([{ id: 'ok' }]);
321
+ await room.destroy();
322
+ });
323
+ });
@@ -6,9 +6,12 @@
6
6
  // via a synthetic single-target run that short-circuits screenshot.
7
7
 
8
8
  import { describe, expect, test } from 'bun:test';
9
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
10
+ import { tmpdir } from 'node:os';
11
+ import path from 'node:path';
9
12
 
10
13
  import { PDFDocument } from 'pdf-lib';
11
- import { run } from '../../exporters/pdf.ts';
14
+ import { assertTotalSizeOk, MAX_TOTAL_OUTPUT_BYTES, run } from '../../exporters/pdf.ts';
12
15
 
13
16
  const CTX = {
14
17
  designRoot: '/tmp/.design',
@@ -31,6 +34,35 @@ describe('pdf adapter — contract', () => {
31
34
  });
32
35
  });
33
36
 
37
+ describe('pdf adapter — total output-size guard (RCA issue-desktop-print-pdf-save-as-hang-large-payload)', () => {
38
+ test('passes when total bytes are under the ceiling', () => {
39
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-guard-'));
40
+ try {
41
+ const p = path.join(tmp, 'a.pdf');
42
+ writeFileSync(p, Buffer.alloc(1024));
43
+ expect(() => assertTotalSizeOk([p])).not.toThrow();
44
+ } finally {
45
+ rmSync(tmp, { recursive: true, force: true });
46
+ }
47
+ });
48
+
49
+ test('throws an actionable error when total bytes exceed the ceiling', () => {
50
+ const tmp = mkdtempSync(path.join(tmpdir(), 'maude-pdf-guard-'));
51
+ try {
52
+ // Two files, each just over half the ceiling — sums past the limit
53
+ // without needing to actually allocate a giant single buffer.
54
+ const half = Math.ceil(MAX_TOTAL_OUTPUT_BYTES / 2) + 1024;
55
+ const a = path.join(tmp, 'a.pdf');
56
+ const b = path.join(tmp, 'b.pdf');
57
+ writeFileSync(a, Buffer.alloc(half));
58
+ writeFileSync(b, Buffer.alloc(half));
59
+ expect(() => assertTotalSizeOk([a, b])).toThrow(/too large/i);
60
+ } finally {
61
+ rmSync(tmp, { recursive: true, force: true });
62
+ }
63
+ });
64
+ });
65
+
34
66
  describe('pdf-lib smoke — embed + save', () => {
35
67
  test('embeds a PNG and produces a valid PDF buffer', async () => {
36
68
  const pdf = await PDFDocument.create();