@1agh/maude 0.43.0 → 0.45.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 (126) hide show
  1. package/apps/studio/acp/bridge.ts +44 -10
  2. package/apps/studio/acp/env.ts +23 -0
  3. package/apps/studio/acp/login-state.ts +263 -0
  4. package/apps/studio/acp/plugin-bootstrap.ts +31 -25
  5. package/apps/studio/acp/probe.ts +152 -1
  6. package/apps/studio/annotations-layer.tsx +396 -46
  7. package/apps/studio/api.ts +395 -2
  8. package/apps/studio/bin/_audio-search.mjs +140 -0
  9. package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
  10. package/apps/studio/bin/_import-asset.mjs +815 -0
  11. package/apps/studio/bin/_import-brand.mjs +635 -0
  12. package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
  13. package/apps/studio/bin/_import-tokens.mjs +1201 -0
  14. package/apps/studio/bin/_transcribe.mjs +419 -0
  15. package/apps/studio/bin/_transcribe.test.mjs +80 -0
  16. package/apps/studio/bin/audio-search.sh +28 -0
  17. package/apps/studio/bin/generate.sh +154 -0
  18. package/apps/studio/bin/import-asset.sh +34 -0
  19. package/apps/studio/bin/import-brand.sh +29 -0
  20. package/apps/studio/bin/import-tokens.sh +33 -0
  21. package/apps/studio/bin/photo-bg-remove.sh +119 -21
  22. package/apps/studio/bin/transcribe.sh +39 -0
  23. package/apps/studio/canvas-edit.ts +197 -2
  24. package/apps/studio/canvas-lib.tsx +115 -6
  25. package/apps/studio/client/app.jsx +1954 -685
  26. package/apps/studio/client/generate-dialog.jsx +352 -0
  27. package/apps/studio/client/github.js +14 -0
  28. package/apps/studio/client/inspector-controls.jsx +781 -0
  29. package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
  30. package/apps/studio/client/panels/ChatPanel.jsx +15 -33
  31. package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
  32. package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
  33. package/apps/studio/client/panels/ReadinessList.jsx +227 -7
  34. package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
  35. package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
  36. package/apps/studio/client/photo-knobs.jsx +167 -172
  37. package/apps/studio/client/styles/3-shell-maude.css +334 -28
  38. package/apps/studio/client/styles/4-components.css +223 -0
  39. package/apps/studio/client/styles/6-acp-chat.css +86 -0
  40. package/apps/studio/client/tour/quick-setup-tour.js +39 -0
  41. package/apps/studio/commands/annotation-strokes-command.ts +13 -3
  42. package/apps/studio/config.schema.json +70 -0
  43. package/apps/studio/context-menu.tsx +11 -2
  44. package/apps/studio/context.ts +16 -0
  45. package/apps/studio/design-setup-readiness.ts +115 -0
  46. package/apps/studio/dist/client.bundle.js +2097 -2088
  47. package/apps/studio/dist/styles.css +1 -1
  48. package/apps/studio/draw/palette.ts +34 -0
  49. package/apps/studio/footage/schema.test.ts +92 -0
  50. package/apps/studio/footage/schema.ts +179 -1
  51. package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
  52. package/apps/studio/generation/adapters/gemini.ts +542 -0
  53. package/apps/studio/generation/adapters/groq.test.ts +80 -0
  54. package/apps/studio/generation/adapters/groq.ts +247 -0
  55. package/apps/studio/generation/audio-library.test.ts +95 -0
  56. package/apps/studio/generation/audio-library.ts +156 -0
  57. package/apps/studio/generation/captions.test.ts +78 -0
  58. package/apps/studio/generation/captions.ts +132 -0
  59. package/apps/studio/generation/download.ts +76 -0
  60. package/apps/studio/generation/elevenlabs.test.ts +223 -0
  61. package/apps/studio/generation/gemini.test.ts +350 -0
  62. package/apps/studio/generation/jobs.test.ts +128 -0
  63. package/apps/studio/generation/jobs.ts +243 -0
  64. package/apps/studio/generation/keys.test.ts +75 -0
  65. package/apps/studio/generation/keys.ts +162 -0
  66. package/apps/studio/generation/prefs.test.ts +95 -0
  67. package/apps/studio/generation/prefs.ts +82 -0
  68. package/apps/studio/generation/registry.ts +88 -0
  69. package/apps/studio/generation/types.test.ts +123 -0
  70. package/apps/studio/generation/types.ts +266 -0
  71. package/apps/studio/generation/whisper-models.test.ts +152 -0
  72. package/apps/studio/generation/whisper-models.ts +257 -0
  73. package/apps/studio/generation/whisper-spike-results.md +61 -0
  74. package/apps/studio/git/service.ts +3 -1
  75. package/apps/studio/http.ts +867 -9
  76. package/apps/studio/media/intro.mp4 +0 -0
  77. package/apps/studio/media-commit-chain.ts +119 -0
  78. package/apps/studio/paths.ts +10 -0
  79. package/apps/studio/readiness.ts +108 -20
  80. package/apps/studio/scaffold-design.ts +1145 -0
  81. package/apps/studio/server.ts +13 -1
  82. package/apps/studio/test/_helpers.ts +15 -2
  83. package/apps/studio/test/acp-bridge.test.ts +34 -1
  84. package/apps/studio/test/acp-env.test.ts +30 -0
  85. package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
  86. package/apps/studio/test/acp-session-plugins.test.ts +72 -1
  87. package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
  88. package/apps/studio/test/annotations-layer.test.ts +174 -0
  89. package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
  90. package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
  91. package/apps/studio/test/csrf-write-guard.test.ts +23 -1
  92. package/apps/studio/test/design-setup-readiness.test.ts +155 -0
  93. package/apps/studio/test/element-structural-edit.test.ts +108 -0
  94. package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
  95. package/apps/studio/test/generate-route.test.ts +106 -0
  96. package/apps/studio/test/import-asset-browser.test.ts +64 -0
  97. package/apps/studio/test/import-asset-route.test.ts +71 -0
  98. package/apps/studio/test/import-asset.test.ts +399 -0
  99. package/apps/studio/test/import-brand-browser.test.ts +88 -0
  100. package/apps/studio/test/import-brand-route.test.ts +120 -0
  101. package/apps/studio/test/import-brand.test.ts +206 -0
  102. package/apps/studio/test/import-tokens.test.ts +722 -0
  103. package/apps/studio/test/media-commit-chain.test.ts +210 -0
  104. package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
  105. package/apps/studio/test/readiness.test.ts +13 -11
  106. package/apps/studio/test/scaffold-design.test.ts +122 -0
  107. package/apps/studio/test/tour-overlay.test.tsx +16 -0
  108. package/apps/studio/tool-palette.tsx +11 -6
  109. package/apps/studio/ui-prefs.ts +130 -0
  110. package/apps/studio/use-canvas-media-drop.tsx +4 -2
  111. package/apps/studio/use-chrome-visibility.tsx +8 -2
  112. package/apps/studio/whats-new.json +96 -0
  113. package/apps/studio/ws.ts +5 -0
  114. package/cli/bin/maude.mjs +6 -6
  115. package/cli/commands/design.mjs +33 -0
  116. package/cli/commands/init.mjs +7 -1
  117. package/cli/lib/gitignore-block.mjs +1 -0
  118. package/cli/lib/pkg-root.mjs +107 -0
  119. package/cli/lib/pkg-root.test.mjs +79 -0
  120. package/cli/lib/reconstruct-toolset.test.mjs +194 -0
  121. package/cli/lib/update-check.mjs +8 -0
  122. package/package.json +12 -10
  123. package/plugins/design/dependencies.json +18 -0
  124. package/plugins/flow/.claude-plugin/config.schema.json +5 -0
  125. package/plugins/flow/templates/ai-skeleton/scenario-guide.md +39 -0
  126. package/plugins/flow/templates/ai-skeleton/scenarios/README.md +2 -0
@@ -18,7 +18,7 @@ import { describe, expect, test } from 'bun:test';
18
18
  import { readFileSync } from 'node:fs';
19
19
  import { fileURLToPath } from 'node:url';
20
20
 
21
- import { sameOriginWrite } from '../http.ts';
21
+ import { sameOriginRead, sameOriginWrite } from '../http.ts';
22
22
 
23
23
  const SELF = 'http://localhost:4399';
24
24
  const post = (origin?: string): Request =>
@@ -53,6 +53,28 @@ describe('CSRF Origin guard — sameOriginWrite (DDR-105)', () => {
53
53
  });
54
54
  });
55
55
 
56
+ describe('CSRF read guard — sameOriginRead (Task 2.5 F1, Fetch-Metadata)', () => {
57
+ const get = (secFetchSite?: string): Request =>
58
+ new Request(`${SELF}/_api/generate/audio-search?q=x`, {
59
+ method: 'GET',
60
+ headers: { ...(secFetchSite ? { 'sec-fetch-site': secFetchSite } : {}) },
61
+ });
62
+
63
+ test('rejects a cross-site browser GET (key-bearing fan-out must not be CSRF-triggerable)', () => {
64
+ expect(sameOriginRead(get('cross-site'))).toBe(false);
65
+ expect(sameOriginRead(get('same-site'))).toBe(false);
66
+ });
67
+
68
+ test('allows a same-origin browser GET', () => {
69
+ expect(sameOriginRead(get('same-origin'))).toBe(true);
70
+ expect(sameOriginRead(get('none'))).toBe(true); // direct navigation
71
+ });
72
+
73
+ test('allows a non-browser client (CLI / curl — no Sec-Fetch-Site header)', () => {
74
+ expect(sameOriginRead(get())).toBe(true);
75
+ });
76
+ });
77
+
56
78
  // phase-30 / DDR-120: the ai-activity bridge now projects `/_api/ai/*` POSTs
57
79
  // onto room awareness, which crosses the hub to every connected peer. So a
58
80
  // forged cross-origin POST to /start /heartbeat /end is no longer a harmless
@@ -0,0 +1,155 @@
1
+ // Design-setup readiness probe (DDR-166 plan, Phase 2 / T6). A pure function
2
+ // over a Context-shaped fixture — no server boot needed, `probeSetupReadiness`
3
+ // only touches `ctx.cfg` + `ctx.paths` (both destructured, so a minimal fake
4
+ // satisfies the type).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
8
+ import { tmpdir } from 'node:os';
9
+ import { join } from 'node:path';
10
+
11
+ import type { Context, DevServerConfig } from '../context.ts';
12
+ import { probeSetupReadiness } from '../design-setup-readiness.ts';
13
+
14
+ function fixtureCtx(opts: {
15
+ hasConfig?: boolean;
16
+ designSystems?: DevServerConfig['designSystems'];
17
+ canvasGroups?: DevServerConfig['canvasGroups'];
18
+ seedCanvas?: boolean;
19
+ seedTokens?: string[]; // ds names to write a real colors_and_type.css for
20
+ seedLogo?: string[]; // ds names to write a preview/logo.svg for
21
+ }): Context {
22
+ const root = mkdtempSync(join(tmpdir(), 'maude-setup-readiness-'));
23
+ const designRoot = join(root, '.design');
24
+ mkdirSync(designRoot, { recursive: true });
25
+
26
+ const designSystems = opts.designSystems ?? [];
27
+ const canvasGroups = opts.canvasGroups ?? [
28
+ { label: 'Design system', path: 'system' },
29
+ { label: 'UI kit', path: 'ui' },
30
+ ];
31
+
32
+ for (const ds of designSystems) {
33
+ mkdirSync(join(designRoot, ds.path), { recursive: true });
34
+ if (opts.seedTokens?.includes(ds.name)) {
35
+ writeFileSync(join(designRoot, ds.path, 'colors_and_type.css'), ':root{}');
36
+ }
37
+ if (opts.seedLogo?.includes(ds.name)) {
38
+ mkdirSync(join(designRoot, ds.path, 'preview'), { recursive: true });
39
+ writeFileSync(join(designRoot, ds.path, 'preview', 'logo.svg'), '<svg/>');
40
+ }
41
+ }
42
+ if (opts.seedCanvas) {
43
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
44
+ writeFileSync(
45
+ join(designRoot, 'ui', 'Welcome.tsx'),
46
+ 'export default function W(){return <main/>}\n'
47
+ );
48
+ }
49
+
50
+ const cfg: DevServerConfig = {
51
+ name: 'test',
52
+ projectLabel: null,
53
+ designRoot: '.design',
54
+ canvasGroups,
55
+ designSystems,
56
+ rootClass: 'app',
57
+ themeDefault: 'dark',
58
+ tokensCssRel: 'system/colors_and_type.css',
59
+ teamAccentDefault: null,
60
+ handoffTargets: [],
61
+ newCanvasDir: 'ui',
62
+ newComponentDir: 'ui/components',
63
+ _source: opts.hasConfig === false ? 'defaults' : '.design/config.json',
64
+ };
65
+
66
+ return {
67
+ cfg,
68
+ projectLabel: 'test',
69
+ paths: {
70
+ repoRoot: root,
71
+ designRel: '.design',
72
+ designRoot,
73
+ serverInfoFile: join(designRoot, '_server.json'),
74
+ activeFile: join(designRoot, '_active.json'),
75
+ commentsDir: join(designRoot, '_comments'),
76
+ canvasStateDir: join(designRoot, '_canvas-state'),
77
+ historyDir: join(designRoot, '_history'),
78
+ tokensUrlRel: 'system/colors_and_type.css',
79
+ systemDirRel: 'system',
80
+ },
81
+ bus: { on: () => () => {}, emit: () => {} },
82
+ } as Context;
83
+ }
84
+
85
+ describe('probeSetupReadiness', () => {
86
+ test('bare write_minimal_design scaffold: config missing, everything else missing too', async () => {
87
+ const report = await probeSetupReadiness(fixtureCtx({ hasConfig: false }));
88
+ expect(report.ready).toBe(false);
89
+ const byId = Object.fromEntries(report.items.map((i) => [i.id, i.status]));
90
+ expect(byId).toEqual({
91
+ project: 'missing',
92
+ 'design-system': 'missing',
93
+ 'first-canvas': 'missing',
94
+ 'brand-assets': 'missing',
95
+ });
96
+ });
97
+
98
+ test('real config, no DS, no canvas: project present, rest missing', async () => {
99
+ const report = await probeSetupReadiness(fixtureCtx({ hasConfig: true }));
100
+ const byId = Object.fromEntries(report.items.map((i) => [i.id, i.status]));
101
+ expect(byId.project).toBe('present');
102
+ expect(byId['design-system']).toBe('missing');
103
+ expect(byId['first-canvas']).toBe('missing');
104
+ expect(byId['brand-assets']).toBe('missing');
105
+ expect(report.ready).toBe(false);
106
+ });
107
+
108
+ test('DS declared but tokens file absent: still missing (declared != on-disk)', async () => {
109
+ const report = await probeSetupReadiness(
110
+ fixtureCtx({
111
+ hasConfig: true,
112
+ designSystems: [
113
+ { name: 'acme', path: 'system/acme', tokensCssRel: 'system/acme/colors_and_type.css' },
114
+ ],
115
+ })
116
+ );
117
+ const item = report.items.find((i) => i.id === 'design-system')!;
118
+ expect(item.status).toBe('missing');
119
+ expect(item.detail).toContain('Declared in config');
120
+ });
121
+
122
+ test('all four present → ready: true', async () => {
123
+ const report = await probeSetupReadiness(
124
+ fixtureCtx({
125
+ hasConfig: true,
126
+ designSystems: [
127
+ { name: 'acme', path: 'system/acme', tokensCssRel: 'system/acme/colors_and_type.css' },
128
+ ],
129
+ seedTokens: ['acme'],
130
+ seedLogo: ['acme'],
131
+ seedCanvas: true,
132
+ })
133
+ );
134
+ expect(report.items.every((i) => i.status === 'present')).toBe(true);
135
+ expect(report.ready).toBe(true);
136
+ });
137
+
138
+ test('a "system" canvas group never counts as the first user canvas', async () => {
139
+ const root = mkdtempSync(join(tmpdir(), 'maude-setup-readiness-'));
140
+ const designRoot = join(root, '.design');
141
+ const canvasGroups = [{ label: 'Design system', path: 'system' }];
142
+ mkdirSync(join(designRoot, 'system'), { recursive: true });
143
+ writeFileSync(
144
+ join(designRoot, 'system', 'specimen.tsx'),
145
+ 'export default function S(){return <main/>}\n'
146
+ );
147
+ const ctx = fixtureCtx({ hasConfig: true, canvasGroups });
148
+ // Overwrite paths.designRoot/repoRoot to point at this second fixture dir
149
+ // (fixtureCtx already made its own scratch dir we don't need here).
150
+ ctx.paths.repoRoot = root;
151
+ ctx.paths.designRoot = designRoot;
152
+ const report = await probeSetupReadiness(ctx);
153
+ expect(report.items.find((i) => i.id === 'first-canvas')!.status).toBe('missing');
154
+ });
155
+ });
@@ -12,6 +12,8 @@ import {
12
12
  applyInsertElement,
13
13
  applyInsertElementIntoArtboard,
14
14
  applyResizeArtboard,
15
+ applySetArtboardHug,
16
+ applySetArtboardStyle,
15
17
  CanvasEditError,
16
18
  } from '../canvas-edit.ts';
17
19
  import { transpileCanvasSource } from '../canvas-pipeline.ts';
@@ -231,6 +233,112 @@ describe('canvas-edit / applyResizeArtboard (D4)', () => {
231
233
  });
232
234
  });
233
235
 
236
+ describe('canvas-edit / applySetArtboardHug (Hug ⇄ Fixed toggle)', () => {
237
+ const canvas = [
238
+ 'export default function Demo() {',
239
+ ' return (',
240
+ ' <DesignCanvas>',
241
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
242
+ ' <div>content</div>',
243
+ ' </DCArtboard>',
244
+ ' </DesignCanvas>',
245
+ ' );',
246
+ '}',
247
+ ].join('\n');
248
+
249
+ test('fixed=true adds the bare boolean prop, no string/expr value', () => {
250
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', true);
251
+ expect(out.source).toMatch(/<DCArtboard\b[^>]*\bfixed\b(?!=)[^>]*>/);
252
+ expect(out.source).not.toContain('fixed={true}');
253
+ expect(out.source).not.toContain('fixed="true"');
254
+ expect(parses(out.source)).toBe(true);
255
+ });
256
+
257
+ test('fixed=true with freezeHeight also pins height (numeric prop)', () => {
258
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', true, 777);
259
+ expect(out.source).toContain('fixed');
260
+ expect(out.source).toContain('height={777}');
261
+ expect(parses(out.source)).toBe(true);
262
+ });
263
+
264
+ test('fixed=true is idempotent — no duplicate attr on a board already pinned', () => {
265
+ const once = applySetArtboardHug(CANVAS, canvas, 'home', true);
266
+ const twice = applySetArtboardHug(CANVAS, once.source, 'home', true);
267
+ expect(twice.source.match(/\bfixed\b/g)?.length).toBe(1);
268
+ });
269
+
270
+ test('fixed=false removes the prop, height untouched', () => {
271
+ const pinned = applySetArtboardHug(CANVAS, canvas, 'home', true, 777);
272
+ const out = applySetArtboardHug(CANVAS, pinned.source, 'home', false);
273
+ expect(out.source).not.toContain('fixed');
274
+ expect(out.source).toContain('height={777}'); // stays as the hug floor
275
+ expect(parses(out.source)).toBe(true);
276
+ });
277
+
278
+ test('fixed=false on an already-hug board is a no-op', () => {
279
+ const out = applySetArtboardHug(CANVAS, canvas, 'home', false);
280
+ expect(out.source).toBe(canvas);
281
+ });
282
+
283
+ test('unknown artboard id throws', () => {
284
+ expect(() => applySetArtboardHug(CANVAS, canvas, 'nope', true)).toThrow(CanvasEditError);
285
+ });
286
+ });
287
+
288
+ describe('canvas-edit / applySetArtboardStyle (background/padding/layout/gap)', () => {
289
+ const canvas = [
290
+ 'export default function Demo() {',
291
+ ' return (',
292
+ ' <DesignCanvas>',
293
+ ' <DCArtboard id="home" label="Home" width={1440} height={1024}>',
294
+ ' <div>content</div>',
295
+ ' </DCArtboard>',
296
+ ' </DesignCanvas>',
297
+ ' );',
298
+ '}',
299
+ ].join('\n');
300
+
301
+ test('writes background/layout as string literals, padding/gap as numeric', () => {
302
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', {
303
+ background: 'var(--bg-1)',
304
+ layout: 'flex-col',
305
+ padding: 16,
306
+ gap: 8,
307
+ });
308
+ expect(out.source).toContain('background="var(--bg-1)"');
309
+ expect(out.source).toContain('layout="flex-col"');
310
+ expect(out.source).toContain('padding={16}');
311
+ expect(out.source).toContain('gap={8}');
312
+ expect(parses(out.source)).toBe(true);
313
+ });
314
+
315
+ test('padding/gap of 0 is written verbatim, not clamped to 1', () => {
316
+ const out = applySetArtboardStyle(CANVAS, canvas, 'home', { padding: 0, gap: 0 });
317
+ expect(out.source).toContain('padding={0}');
318
+ expect(out.source).toContain('gap={0}');
319
+ });
320
+
321
+ test('null resets a previously-set prop (removes the attribute)', () => {
322
+ const styled = applySetArtboardStyle(CANVAS, canvas, 'home', { background: 'red' });
323
+ const out = applySetArtboardStyle(CANVAS, styled.source, 'home', { background: null });
324
+ expect(out.source).not.toContain('background=');
325
+ expect(parses(out.source)).toBe(true);
326
+ });
327
+
328
+ test('an absent key leaves that prop untouched', () => {
329
+ const styled = applySetArtboardStyle(CANVAS, canvas, 'home', { background: 'red', padding: 4 });
330
+ const out = applySetArtboardStyle(CANVAS, styled.source, 'home', { padding: 8 });
331
+ expect(out.source).toContain('background="red"'); // untouched
332
+ expect(out.source).toContain('padding={8}');
333
+ });
334
+
335
+ test('unknown artboard id throws', () => {
336
+ expect(() => applySetArtboardStyle(CANVAS, canvas, 'nope', { padding: 4 })).toThrow(
337
+ CanvasEditError
338
+ );
339
+ });
340
+ });
341
+
234
342
  describe('canvas-edit / applyInsertArtboard (I4)', () => {
235
343
  const canvas = [
236
344
  'export default function Demo() {',
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Test fixture standing in for the `claude` CLI — implements only the one
3
+ // subcommand probeAcpAvailabilityAuthed()/getClaudeAuthStatus() shell out to.
4
+ // FAKE_CLAUDE_LOGGED_IN=1 controls the reported sign-in state.
5
+ const [, , cmd, sub] = process.argv;
6
+ if (cmd === 'auth' && sub === 'status') {
7
+ const loggedIn = process.env.FAKE_CLAUDE_LOGGED_IN === '1';
8
+ process.stdout.write(
9
+ JSON.stringify({ loggedIn, apiProvider: loggedIn ? 'firstParty' : undefined })
10
+ );
11
+ process.exit(0);
12
+ }
13
+ process.exit(1);
@@ -0,0 +1,106 @@
1
+ // generate-route.test.ts — the privileged /_api/generate* routes on the MAIN
2
+ // origin. Boots a real server (no CANVAS_ORIGIN_SPLIT; loopback host). Proves:
3
+ // validation rejects bad bodies; a key POST reports {configured} and GET never
4
+ // echoes the value; the provider catalogue serializes with a presence flag; and
5
+ // a generate POST enqueues a job (202 {jobId}) that fails cleanly with no key
6
+ // configured (no real Google call). Points MAUDE_GEN_KEYS_PATH at the sandbox
7
+ // so the user's real key store is never touched.
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { mkdtempSync, rmSync } from 'node:fs';
11
+ import { tmpdir } from 'node:os';
12
+ import { join } from 'node:path';
13
+
14
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
15
+
16
+ let keysDir: string;
17
+ beforeEach(() => {
18
+ keysDir = mkdtempSync(join(tmpdir(), 'maude-genroute-'));
19
+ });
20
+ afterEach(() => rmSync(keysDir, { recursive: true, force: true }));
21
+
22
+ describe('/_api/generate* main-origin routes', () => {
23
+ test('providers catalogue, key custody (no echo), validation, and enqueue', async () => {
24
+ const { root } = makeSandbox();
25
+ const port = nextPort();
26
+ const proc = await bootServer(root, port, {
27
+ MAUDE_GEN_KEYS_PATH: join(keysDir, 'keys.json'),
28
+ });
29
+ const base = `http://localhost:${port}`;
30
+ try {
31
+ // Provider catalogue — gemini present, not yet configured.
32
+ const provRes = await fetch(`${base}/_api/generate/providers`);
33
+ expect(provRes.status).toBe(200);
34
+ const provJson = (await provRes.json()) as {
35
+ providers: Array<{ id: string; configured: boolean }>;
36
+ };
37
+ const gemini = provJson.providers.find((p) => p.id === 'gemini');
38
+ expect(gemini).toBeTruthy();
39
+ expect(gemini?.configured).toBe(false);
40
+
41
+ // Set a key → {configured:true}; the value is NEVER echoed back.
42
+ const setRes = await fetch(`${base}/_api/generate/keys`, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'text/plain' },
45
+ body: JSON.stringify({ provider: 'gemini', key: 'AIza-super-secret' }),
46
+ });
47
+ expect(setRes.status).toBe(200);
48
+ const setJson = (await setRes.json()) as Record<string, unknown>;
49
+ expect(setJson.configured).toBe(true);
50
+ expect(JSON.stringify(setJson)).not.toContain('super-secret');
51
+
52
+ // GET key status → presence only, no value.
53
+ const statusRes = await fetch(`${base}/_api/generate/keys`);
54
+ const statusJson = (await statusRes.json()) as { configured: string[] };
55
+ expect(statusJson.configured).toContain('gemini');
56
+ expect(JSON.stringify(statusJson)).not.toContain('super-secret');
57
+
58
+ // Validation — a malformed generate body 400s before any provider call.
59
+ const badRes = await fetch(`${base}/_api/generate-jobs`, {
60
+ method: 'POST',
61
+ headers: { 'content-type': 'text/plain' },
62
+ body: JSON.stringify({ modality: 'hologram', provider: '../etc' }),
63
+ });
64
+ expect(badRes.status).toBe(400);
65
+
66
+ // Unknown provider 400s.
67
+ const unkRes = await fetch(`${base}/_api/generate-jobs`, {
68
+ method: 'POST',
69
+ headers: { 'content-type': 'text/plain' },
70
+ body: JSON.stringify({ modality: 'image', provider: 'nope', prompt: 'x' }),
71
+ });
72
+ expect(unkRes.status).toBe(400);
73
+
74
+ // A well-formed request enqueues → 202 { jobId }. (The run will hit the
75
+ // real Gemini endpoint with a bogus key and fail in the background — we
76
+ // only assert the queue accepted it and surfaces the job.)
77
+ const okRes = await fetch(`${base}/_api/generate-jobs`, {
78
+ method: 'POST',
79
+ headers: { 'content-type': 'text/plain' },
80
+ body: JSON.stringify({ modality: 'image', provider: 'gemini', prompt: 'a red circle' }),
81
+ });
82
+ expect(okRes.status).toBe(202);
83
+ const okJson = (await okRes.json()) as { jobId: string };
84
+ expect(okJson.jobId).toMatch(/^gen_/);
85
+
86
+ // The job shows up in the list.
87
+ const listRes = await fetch(`${base}/_api/generate-jobs`);
88
+ const listJson = (await listRes.json()) as { jobs: Array<{ id: string }> };
89
+ expect(listJson.jobs.some((j) => j.id === okJson.jobId)).toBe(true);
90
+
91
+ // Remove the key.
92
+ const delRes = await fetch(`${base}/_api/generate/keys`, {
93
+ method: 'DELETE',
94
+ headers: { 'content-type': 'text/plain' },
95
+ body: JSON.stringify({ provider: 'gemini' }),
96
+ });
97
+ expect(delRes.status).toBe(200);
98
+ const afterDel = (await (await fetch(`${base}/_api/generate/keys`)).json()) as {
99
+ configured: string[];
100
+ };
101
+ expect(afterDel.configured).not.toContain('gemini');
102
+ } finally {
103
+ await killProc(proc);
104
+ }
105
+ });
106
+ });
@@ -0,0 +1,64 @@
1
+ // Browser-driven pieces of local-file/SVG/PDF ingestion (DDR-167) — the SVG
2
+ // execution canary. Spawns real agent-browser sessions, so this is slower
3
+ // than import-asset.test.ts and is skipped (not failed) when agent-browser
4
+ // can't be resolved, mirroring how the rest of this codebase treats
5
+ // browser-dependent verification as a real-environment concern rather than a
6
+ // hard CI requirement (e.g. ensure-browser.test.ts's resolution-only tests).
7
+ //
8
+ // DDR-167 requires the SVG canary fixtures be a PERMANENT regression test,
9
+ // not a one-time pre-ship check — this file is that test. The PDF rasterize
10
+ // adversarial fixtures (OpenAction/JS canary, SubmitForm/URI/Launch) are NOT
11
+ // here: live spike-verification found the planned rasterization mechanism
12
+ // (headless-Chromium navigating a file://*.pdf URL) does not render PDF
13
+ // content under agent-browser automation (confirmed blank output on both
14
+ // chrome-headless-shell and full Chrome) — see DDR-167's addendum. PDF
15
+ // rasterization throws "not yet available" rather than shipping broken
16
+ // output; there is nothing to fixture-test until a follow-up mechanism lands.
17
+
18
+ import { describe, expect, test } from 'bun:test';
19
+ import { execFileSync } from 'node:child_process';
20
+
21
+ import { rasterizePdfPage, runSvgExecutionCanary } from '../bin/_import-asset.mjs';
22
+
23
+ function agentBrowserAvailable(): boolean {
24
+ try {
25
+ execFileSync(process.env.MAUDE_AGENT_BROWSER_BIN || 'agent-browser', ['--version'], {
26
+ stdio: 'ignore',
27
+ timeout: 5000,
28
+ });
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+
35
+ const HAS_AGENT_BROWSER = agentBrowserAvailable();
36
+ const d = HAS_AGENT_BROWSER ? describe : describe.skip;
37
+
38
+ d('SVG execution canary (Decision 1, step 6) — permanent regression fixtures', () => {
39
+ test('accepts a clean, allowlist-sanitized SVG (no false positive)', async () => {
40
+ const clean =
41
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><path d="M16 5l2.8 8.2L27 16l-8.2 2.8L16 27l-2.8-8.2L5 16l8.2-2.8z" fill="#4f46e5"/></svg>';
42
+ await expect(runSvgExecutionCanary(clean)).resolves.toBe(clean);
43
+ }, 30_000);
44
+
45
+ test('trips on a raw <script>-bearing SVG (defense-in-depth if the allowlist sanitizer were ever bypassed)', async () => {
46
+ const malicious =
47
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>window.__MAUDE_IMPORT_CANARY__=true;</script></svg>';
48
+ await expect(runSvgExecutionCanary(malicious)).rejects.toThrow(/canary tripped/);
49
+ }, 30_000);
50
+
51
+ test('the sandboxed-render helper does not block the file:// navigation it needs (regression: a bare "*" network route also blocks local file loads)', async () => {
52
+ // A file:// SVG with no scripting at all must still load and be
53
+ // observable — this specifically guards against re-introducing the
54
+ // wildcard-route regression found during implementation.
55
+ const trivial = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="1" height="1"/></svg>';
56
+ await expect(runSvgExecutionCanary(trivial)).resolves.toBeTruthy();
57
+ }, 30_000);
58
+ });
59
+
60
+ d('PDF rasterization — explicitly NOT shipped (DDR-167 addendum)', () => {
61
+ test('rasterizePdfPage throws "not yet available" rather than producing output', async () => {
62
+ await expect(rasterizePdfPage('/nonexistent.pdf', 1)).rejects.toThrow(/not yet available/);
63
+ });
64
+ });
@@ -0,0 +1,71 @@
1
+ // POST /_api/import-asset — DDR-167 (Phase 3 / T10). Full HTTP round-trip
2
+ // against a real booted server (privilege/CSRF boundary is covered generically
3
+ // by canvas-origin-gate.test.ts — this file exercises the route's own
4
+ // success/error shapes).
5
+
6
+ import { describe, expect, test } from 'bun:test';
7
+ import { readFileSync } from 'node:fs';
8
+
9
+ import { bootServer, killProc, makeSandbox, nextPort } from './_helpers.ts';
10
+
11
+ const MALICIOUS_SVG =
12
+ '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script>' +
13
+ '<rect onclick="alert(2)" fill="#4f46e5" width="10" height="10"/></svg>';
14
+
15
+ describe('POST /_api/import-asset', () => {
16
+ test('imports and sanitizes an SVG, rejects a PDF kind as not-yet-available, rejects GET', async () => {
17
+ const { root, designRoot } = makeSandbox();
18
+ const port = nextPort();
19
+ const proc = await bootServer(root, port);
20
+ try {
21
+ const base = `http://localhost:${port}`;
22
+
23
+ const post = await fetch(`${base}/_api/import-asset`, {
24
+ method: 'POST',
25
+ headers: { 'X-Import-Kind': 'svg', Origin: base },
26
+ body: MALICIOUS_SVG,
27
+ });
28
+ expect(post.status).toBe(201);
29
+ const body = (await post.json()) as { ok: boolean; path: string };
30
+ expect(body.ok).toBe(true);
31
+ expect(body.path).toMatch(/^assets\/[a-z0-9]{8}\.svg$/);
32
+ const written = readFileSync(`${designRoot}/${body.path}`, 'utf8');
33
+ expect(written).not.toContain('script');
34
+ expect(written).not.toContain('onclick');
35
+
36
+ const pdfAttempt = await fetch(`${base}/_api/import-asset`, {
37
+ method: 'POST',
38
+ headers: { 'X-Import-Kind': 'pdf', Origin: base },
39
+ body: '%PDF-1.4 fixture',
40
+ });
41
+ expect(pdfAttempt.status).toBe(501);
42
+ const pdfBody = (await pdfAttempt.json()) as { ok: boolean; error: string };
43
+ expect(pdfBody.ok).toBe(false);
44
+ expect(pdfBody.error).toContain('not yet available');
45
+
46
+ const getAttempt = await fetch(`${base}/_api/import-asset`, { method: 'GET' });
47
+ expect(getAttempt.status).toBe(405);
48
+ } finally {
49
+ await killProc(proc);
50
+ }
51
+ }, 30_000);
52
+
53
+ test('a malformed SVG is rejected with a clean 400, not a 500 crash', async () => {
54
+ const { root } = makeSandbox();
55
+ const port = nextPort();
56
+ const proc = await bootServer(root, port);
57
+ try {
58
+ const base = `http://localhost:${port}`;
59
+ const res = await fetch(`${base}/_api/import-asset`, {
60
+ method: 'POST',
61
+ headers: { 'X-Import-Kind': 'svg', Origin: base },
62
+ body: '<svg><rect',
63
+ });
64
+ expect(res.status).toBe(400);
65
+ const body = (await res.json()) as { ok: boolean };
66
+ expect(body.ok).toBe(false);
67
+ } finally {
68
+ await killProc(proc);
69
+ }
70
+ }, 30_000);
71
+ });