@1agh/maude 0.58.2 → 0.59.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 (140) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/annotations-layer.tsx +49 -15
  3. package/apps/studio/api.ts +6 -1
  4. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  5. package/apps/studio/bin/_import-asset.mjs +90 -0
  6. package/apps/studio/bin/_import-figma.mjs +1775 -0
  7. package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
  8. package/apps/studio/bin/_perf-probe.mjs +228 -0
  9. package/apps/studio/bin/_perf-shared.mjs +345 -0
  10. package/apps/studio/bin/_video-playwright.mjs +103 -7
  11. package/apps/studio/bin/import-figma.sh +47 -0
  12. package/apps/studio/bin/perf.sh +228 -0
  13. package/apps/studio/bin/read-annotations.mjs +11 -1
  14. package/apps/studio/bin/smoke.sh +49 -5
  15. package/apps/studio/bun.lock +16 -22
  16. package/apps/studio/canvas-edit.ts +29 -5
  17. package/apps/studio/canvas-lib.tsx +148 -6
  18. package/apps/studio/client/app.jsx +196 -38
  19. package/apps/studio/client/export-center.jsx +42 -4
  20. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  21. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  22. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  23. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  24. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  25. package/apps/studio/client/panels/SyncPanel.jsx +229 -0
  26. package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
  27. package/apps/studio/client/panels/timeline-comp-target.js +101 -0
  28. package/apps/studio/client/panels/timeline-parse.js +3 -3
  29. package/apps/studio/client/styles/3-shell-maude.css +37 -0
  30. package/apps/studio/client/styles/4-components.css +134 -0
  31. package/apps/studio/clip-ops.ts +93 -17
  32. package/apps/studio/cloud/endpoints.ts +78 -10
  33. package/apps/studio/cloud/renew.ts +183 -0
  34. package/apps/studio/context.ts +2 -1
  35. package/apps/studio/dist/client.bundle.js +1231 -1231
  36. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  37. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  38. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  39. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  40. package/apps/studio/dist/runtime/remotion.js +12 -12
  41. package/apps/studio/dist/styles.css +1 -1
  42. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  43. package/apps/studio/exporters/_runtime.ts +19 -0
  44. package/apps/studio/exporters/degraded.ts +92 -0
  45. package/apps/studio/exporters/index.ts +5 -0
  46. package/apps/studio/exporters/jobs.ts +19 -0
  47. package/apps/studio/exporters/unsupported-media.ts +170 -0
  48. package/apps/studio/exporters/video-encode-lib.ts +35 -6
  49. package/apps/studio/exporters/video-render-lib.ts +6 -0
  50. package/apps/studio/exporters/video.ts +72 -1
  51. package/apps/studio/figma/assets.test.ts +464 -0
  52. package/apps/studio/figma/assets.ts +452 -0
  53. package/apps/studio/figma/client.test.ts +395 -0
  54. package/apps/studio/figma/client.ts +513 -0
  55. package/apps/studio/figma/codegen-client.test.ts +276 -0
  56. package/apps/studio/figma/codegen-client.ts +509 -0
  57. package/apps/studio/figma/codegen-fonts.test.ts +103 -0
  58. package/apps/studio/figma/codegen-fonts.ts +195 -0
  59. package/apps/studio/figma/codegen-values.test.ts +179 -0
  60. package/apps/studio/figma/codegen-values.ts +270 -0
  61. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  62. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  63. package/apps/studio/figma/endpoints.ts +273 -0
  64. package/apps/studio/figma/fig-decode.test.ts +702 -0
  65. package/apps/studio/figma/fig-decode.ts +617 -0
  66. package/apps/studio/figma/fig-kiwi.ts +410 -0
  67. package/apps/studio/figma/fig-zip.ts +270 -0
  68. package/apps/studio/figma/from-codegen.test.ts +408 -0
  69. package/apps/studio/figma/from-codegen.ts +1103 -0
  70. package/apps/studio/figma/sanitize.test.ts +325 -0
  71. package/apps/studio/figma/sanitize.ts +407 -0
  72. package/apps/studio/figma/style-map.ts +352 -0
  73. package/apps/studio/figma/tailwind-map.test.ts +142 -0
  74. package/apps/studio/figma/tailwind-map.ts +545 -0
  75. package/apps/studio/figma/to-artboard.test.ts +808 -0
  76. package/apps/studio/figma/to-artboard.ts +701 -0
  77. package/apps/studio/figma/to-render.test.ts +180 -0
  78. package/apps/studio/figma/to-render.ts +328 -0
  79. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  80. package/apps/studio/figma/to-strokes.test.ts +705 -0
  81. package/apps/studio/figma/to-strokes.ts +749 -0
  82. package/apps/studio/figma/to-tokens.test.ts +321 -0
  83. package/apps/studio/figma/to-tokens.ts +305 -0
  84. package/apps/studio/figma/types.ts +544 -0
  85. package/apps/studio/figma/url.test.ts +167 -0
  86. package/apps/studio/figma/url.ts +160 -0
  87. package/apps/studio/http.ts +176 -0
  88. package/apps/studio/sync/asset-push.ts +432 -0
  89. package/apps/studio/sync/connection-state.ts +82 -3
  90. package/apps/studio/sync/hub-link.ts +63 -7
  91. package/apps/studio/sync/hubs-config.ts +31 -3
  92. package/apps/studio/sync/index.ts +286 -27
  93. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  94. package/apps/studio/sync/presentation.ts +45 -1
  95. package/apps/studio/sync/status.ts +18 -0
  96. package/apps/studio/sync/supervisor.ts +5 -1
  97. package/apps/studio/sync/workspace-signin.ts +7 -3
  98. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  99. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  100. package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
  101. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  102. package/apps/studio/test/clip-addressing.test.ts +6 -1
  103. package/apps/studio/test/clip-ops.test.ts +5 -1
  104. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  105. package/apps/studio/test/cloud-renew.test.ts +205 -0
  106. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  107. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  108. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  109. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  110. package/apps/studio/test/figma-explode.test.ts +438 -0
  111. package/apps/studio/test/figma-provenance.test.ts +108 -0
  112. package/apps/studio/test/figma-routes.test.ts +294 -0
  113. package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
  114. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  115. package/apps/studio/test/hub-link.test.ts +11 -0
  116. package/apps/studio/test/import-figma.test.ts +667 -0
  117. package/apps/studio/test/sync-asset-push.test.ts +567 -0
  118. package/apps/studio/test/sync-connection-state.test.ts +79 -0
  119. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  120. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  121. package/apps/studio/test/sync-panel-surface.test.ts +90 -0
  122. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  123. package/apps/studio/test/sync-presentation.test.ts +77 -0
  124. package/apps/studio/test/sync-runtime.test.ts +316 -1
  125. package/apps/studio/test/sync-status.test.ts +28 -0
  126. package/apps/studio/test/timeline-comp-target.test.ts +139 -0
  127. package/apps/studio/test/video-comp.test.ts +104 -2
  128. package/apps/studio/test/video-encode-lib.test.ts +63 -0
  129. package/apps/studio/test/workspace-containment.test.ts +1 -0
  130. package/apps/studio/use-artboard-drag.tsx +37 -3
  131. package/apps/studio/video-comp.tsx +121 -6
  132. package/apps/studio/whats-new.json +98 -0
  133. package/apps/studio/workspace-mode.ts +4 -0
  134. package/cli/commands/design.mjs +15 -0
  135. package/cli/commands/kg.mjs +8 -1
  136. package/cli/commands/kg.test.mjs +24 -0
  137. package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
  138. package/cli/lib/figma-import-controls.test.mjs +70 -0
  139. package/package.json +8 -8
  140. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,167 @@
1
+ // figma/url.ts — the SSRF chokepoint-1 rejection table (DDR-216 D4).
2
+ //
3
+ // The accept cases prove the four public URL shapes + bare keys parse; the
4
+ // REJECT table is the load-bearing half. Round 1 of the DDR's security review
5
+ // attacked this parser specifically (userinfo-in-host, IDN look-alikes, `..`,
6
+ // `%2e%2e`, a key carrying `/`/`@`/`#`, a node id smuggling a path, a full
7
+ // absolute URL passed as the key) and could not break it — these cases pin
8
+ // that result so a later "let's be more forgiving" refactor fails loudly.
9
+
10
+ import { describe, expect, test } from 'bun:test';
11
+
12
+ import { FigmaUrlError, normalizeNodeId, parseFigmaTarget } from './url.ts';
13
+
14
+ const DESIGN_KEY = 'dGNzRC2kmrmGnOxaBa0RI7'; // the real design fixture
15
+ const BOARD_KEY = 'Em6NOwaOFTYV7NlQT4NK8l'; // the real FigJam fixture
16
+
17
+ describe('parseFigmaTarget — accepts the four public shapes', () => {
18
+ const cases: Array<[string, string, { fileKey: string; nodeId?: string; surface: string }]> = [
19
+ [
20
+ 'design URL',
21
+ `https://www.figma.com/design/${DESIGN_KEY}/Maude-fixtures`,
22
+ { fileKey: DESIGN_KEY, surface: 'design' },
23
+ ],
24
+ [
25
+ 'board URL',
26
+ `https://www.figma.com/board/${BOARD_KEY}/Analyza`,
27
+ { fileKey: BOARD_KEY, surface: 'board' },
28
+ ],
29
+ [
30
+ 'legacy /file/ URL normalizes to design',
31
+ `https://www.figma.com/file/${DESIGN_KEY}/Old-link`,
32
+ { fileKey: DESIGN_KEY, surface: 'design' },
33
+ ],
34
+ [
35
+ 'proto URL normalizes to design',
36
+ `https://www.figma.com/proto/${DESIGN_KEY}/Proto`,
37
+ { fileKey: DESIGN_KEY, surface: 'design' },
38
+ ],
39
+ [
40
+ 'apex host (no www)',
41
+ `https://figma.com/design/${DESIGN_KEY}/x`,
42
+ { fileKey: DESIGN_KEY, surface: 'design' },
43
+ ],
44
+ [
45
+ 'node-id is normalized from a-b to a:b',
46
+ `https://www.figma.com/design/${DESIGN_KEY}/x?node-id=2-17`,
47
+ { fileKey: DESIGN_KEY, nodeId: '2:17', surface: 'design' },
48
+ ],
49
+ [
50
+ 'node-id already colon-shaped survives',
51
+ `https://www.figma.com/board/${BOARD_KEY}/x?node-id=1%3A8`,
52
+ { fileKey: BOARD_KEY, nodeId: '1:8', surface: 'board' },
53
+ ],
54
+ [
55
+ 'other query params are ignored',
56
+ `https://www.figma.com/design/${DESIGN_KEY}/x?t=abc&node-id=2-17&mode=dev`,
57
+ { fileKey: DESIGN_KEY, nodeId: '2:17', surface: 'design' },
58
+ ],
59
+ ['bare key defaults to design', DESIGN_KEY, { fileKey: DESIGN_KEY, surface: 'design' }],
60
+ ];
61
+
62
+ for (const [label, input, expected] of cases) {
63
+ test(label, () => {
64
+ expect(parseFigmaTarget(input)).toEqual(expected as never);
65
+ });
66
+ }
67
+
68
+ test('a bare key honours the caller-supplied default surface', () => {
69
+ expect(parseFigmaTarget(BOARD_KEY, 'board')).toEqual({ fileKey: BOARD_KEY, surface: 'board' });
70
+ });
71
+
72
+ test('surrounding whitespace from a paste is tolerated', () => {
73
+ expect(parseFigmaTarget(` https://www.figma.com/design/${DESIGN_KEY}/x `).fileKey).toBe(
74
+ DESIGN_KEY
75
+ );
76
+ });
77
+ });
78
+
79
+ describe('parseFigmaTarget — the rejection table (SSRF chokepoint 1)', () => {
80
+ const rejects: Array<[string, string]> = [
81
+ // ── the host must never come from input ──────────────────────────────
82
+ ['userinfo-in-host', `https://www.figma.com@evil.tld/design/${DESIGN_KEY}/x`],
83
+ ['userinfo with password', `https://user:pw@www.figma.com/design/${DESIGN_KEY}/x`],
84
+ ['plain non-figma host', `https://evil.tld/design/${DESIGN_KEY}/x`],
85
+ ['suffix look-alike (evil-figma.com)', `https://evil-figma.com/design/${DESIGN_KEY}/x`],
86
+ ['figma.com as a subdomain of evil', `https://figma.com.evil.tld/design/${DESIGN_KEY}/x`],
87
+ ['IDN homograph', `https://www.figɱa.com/design/${DESIGN_KEY}/x`],
88
+ ['cyrillic homograph', `https://www.figmа.com/design/${DESIGN_KEY}/x`],
89
+ ['loopback', `http://127.0.0.1/design/${DESIGN_KEY}/x`],
90
+ ['link-local metadata host', `http://169.254.169.254/design/${DESIGN_KEY}/x`],
91
+
92
+ // ── scheme ───────────────────────────────────────────────────────────
93
+ ['file scheme', `file:///design/${DESIGN_KEY}/x`],
94
+ ['data scheme', 'data:text/plain,figma'],
95
+ ['javascript scheme', 'javascript:alert(1)'],
96
+
97
+ // ── the key position ─────────────────────────────────────────────────
98
+ ['traversal in the key position', 'https://www.figma.com/design/../../etc/passwd'],
99
+ ['percent-encoded traversal', 'https://www.figma.com/design/%2e%2e%2f%2e%2e/x'],
100
+ ['encoded slash in the key', 'https://www.figma.com/design/aaaa%2Fbbbb/x'],
101
+ ['key too short', 'https://www.figma.com/design/short/x'],
102
+ ['key too long', `https://www.figma.com/design/${'a'.repeat(65)}/x`],
103
+ ['key with a hyphen', 'https://www.figma.com/design/abcd-efghij/x'],
104
+ ['key with a dot', 'https://www.figma.com/design/abcdefghi.j/x'],
105
+ ['no key segment', 'https://www.figma.com/design/'],
106
+ ['unknown surface segment', `https://www.figma.com/community/${DESIGN_KEY}/x`],
107
+
108
+ // ── bare-key position ────────────────────────────────────────────────
109
+ ['bare key with a slash', 'aaaaaaaaaa/bbbb'],
110
+ ['bare key with an @', 'aaaaaaaaaa@evil.tld'],
111
+ ['bare key with a #', 'aaaaaaaaaa#frag'],
112
+ ['an absolute URL passed where a key is expected', 'https://evil.tld/aaaaaaaaaa'],
113
+
114
+ // ── shape ────────────────────────────────────────────────────────────
115
+ ['empty', ''],
116
+ ['whitespace only', ' '],
117
+ ['over-length input', `https://www.figma.com/design/${DESIGN_KEY}/${'x'.repeat(2100)}`],
118
+ ];
119
+
120
+ for (const [label, input] of rejects) {
121
+ test(`rejects: ${label}`, () => {
122
+ expect(() => parseFigmaTarget(input)).toThrow(FigmaUrlError);
123
+ });
124
+ }
125
+
126
+ test('rejection messages never echo the input back', () => {
127
+ // An error path that reflects attacker-controlled input is a namable
128
+ // exfil/reflection channel — DDR-172 Decision 1 closed the same class for
129
+ // token-file parse errors, and DDR-216 D10 makes it a rule for this verb.
130
+ const hostile = 'https://evil.tld/design/CANARYCANARY/x?node-id=1-2';
131
+ try {
132
+ parseFigmaTarget(hostile);
133
+ throw new Error('expected a rejection');
134
+ } catch (err) {
135
+ expect(err).toBeInstanceOf(FigmaUrlError);
136
+ expect((err as Error).message).not.toContain('CANARY');
137
+ expect((err as Error).message).not.toContain('evil.tld');
138
+ }
139
+ });
140
+ });
141
+
142
+ describe('normalizeNodeId — a malformed id means "whole file", never "some other node"', () => {
143
+ test('normalizes the hyphen form', () => {
144
+ expect(normalizeNodeId('457-608')).toBe('457:608');
145
+ });
146
+
147
+ test('passes a well-formed colon id through', () => {
148
+ expect(normalizeNodeId('2:17')).toBe('2:17');
149
+ });
150
+
151
+ for (const bad of [
152
+ null,
153
+ undefined,
154
+ '',
155
+ 'abc',
156
+ '1',
157
+ '1:2:3',
158
+ '../1-2',
159
+ '1-2;rm -rf',
160
+ '1-2 OR 1=1',
161
+ `${'9'.repeat(11)}-1`, // over the digit ceiling
162
+ ]) {
163
+ test(`drops malformed id: ${JSON.stringify(bad)}`, () => {
164
+ expect(normalizeNodeId(bad as string)).toBeUndefined();
165
+ });
166
+ }
167
+ });
@@ -0,0 +1,160 @@
1
+ /**
2
+ * @file figma/url.ts — Figma URL/key parsing (DDR-216 D4, chokepoint 1).
3
+ * @scope apps/studio/figma/url.ts
4
+ * @purpose Turn whatever the user pasted into `{ fileKey, nodeId?, surface }`
5
+ * — and reject everything else.
6
+ *
7
+ * @invariant THIS IS A REJECTION SURFACE, NOT A CONVENIENCE PARSER.
8
+ * **The host is never taken from the input.** Nothing this module
9
+ * returns contributes a scheme, host, port or path prefix to a
10
+ * request; `client.ts` composes every URL from its own hardcoded
11
+ * base plus the charset-validated values below. That is what closes
12
+ * the request-construction SSRF class outright (DDR-216 D4) — the
13
+ * one closure Round 1 of the security review attacked directly and
14
+ * could not break, so keep the property, not just the code.
15
+ *
16
+ * @invariant DEPENDENCY-FREE. No `node:*`, no network, no filesystem. Pure
17
+ * string work, so it is trivially testable and has no way to
18
+ * dereference anything it parses (the DDR-172 Decision 2 discipline,
19
+ * applied to a URL parser instead of an alias resolver).
20
+ */
21
+
22
+ /** The four public Figma URL shapes carry the surface in the path segment. */
23
+ export type FigmaSurface = 'design' | 'board';
24
+
25
+ export interface FigmaTarget {
26
+ /** Charset-validated document key — safe to interpolate into a path segment. */
27
+ fileKey: string;
28
+ /** Normalized `123:456` node id, when the URL carried one. */
29
+ nodeId?: string;
30
+ /**
31
+ * Which editor authored the document. `design` → frames/tokens translators,
32
+ * `board` → the FigJam stroke translator. `/file/` and `/proto/` are legacy
33
+ * design-side shapes and normalize to `design`.
34
+ */
35
+ surface: FigmaSurface;
36
+ }
37
+
38
+ export class FigmaUrlError extends Error {
39
+ constructor(message: string) {
40
+ super(message);
41
+ this.name = 'FigmaUrlError';
42
+ }
43
+ }
44
+
45
+ /**
46
+ * Document keys are opaque base62-ish tokens. The bound is deliberately tight:
47
+ * every character admitted here is one that cannot change the meaning of a URL
48
+ * path segment, so a key can never introduce `/`, `.`, `@`, `#`, `?`, `:` or a
49
+ * percent-escape. Length floor rejects a stray fragment; ceiling rejects a
50
+ * pasted blob.
51
+ */
52
+ const FILE_KEY_RE = /^[A-Za-z0-9]{10,64}$/;
53
+
54
+ /** Figma node ids are always `<number>:<number>` once normalized from `a-b`. */
55
+ const NODE_ID_RE = /^[0-9]{1,10}:[0-9]{1,10}$/;
56
+
57
+ /** Path segment → surface. Anything else is not a document URL we understand. */
58
+ const SURFACE_BY_SEGMENT: Readonly<Record<string, FigmaSurface>> = Object.assign(
59
+ Object.create(null),
60
+ {
61
+ design: 'design',
62
+ board: 'board',
63
+ file: 'design', // legacy design URLs
64
+ proto: 'design', // prototype view of a design file
65
+ }
66
+ );
67
+
68
+ /**
69
+ * Hosts we accept a URL *from*. This is a parse-time sanity check on
70
+ * user-pasted input — NOT the egress control. The egress control is that
71
+ * `client.ts` ignores this host entirely and uses its own constant base
72
+ * (DDR-216 D4). Kept strict anyway so a pasted look-alike fails loudly at the
73
+ * point the user can still see what they pasted.
74
+ */
75
+ function isFigmaHost(host: string): boolean {
76
+ const h = host.toLowerCase();
77
+ return h === 'figma.com' || h.endsWith('.figma.com');
78
+ }
79
+
80
+ /**
81
+ * `node-id` arrives URL-encoded as `123-456` (and historically as `123%3A456`).
82
+ * The API wants `123:456`. Anything that doesn't normalize to exactly that
83
+ * shape is dropped rather than guessed — a malformed node id means "import the
84
+ * whole file", never "import some other node".
85
+ */
86
+ export function normalizeNodeId(raw: string | null | undefined): string | undefined {
87
+ if (!raw) return undefined;
88
+ const candidate = raw.trim().replace(/-/g, ':');
89
+ return NODE_ID_RE.test(candidate) ? candidate : undefined;
90
+ }
91
+
92
+ /**
93
+ * Parse a pasted Figma URL or a bare document key.
94
+ *
95
+ * Accepts:
96
+ * https://www.figma.com/design/<key>/<slug>?node-id=1-2
97
+ * https://figma.com/board/<key>/<slug>
98
+ * https://www.figma.com/file/<key>/… (legacy)
99
+ * https://www.figma.com/proto/<key>/…
100
+ * <key> (bare, surface defaults to `design`)
101
+ *
102
+ * Rejects — with a fixed, input-free message — everything else, including
103
+ * userinfo-in-host (`https://api.figma.com@evil.tld/…`), non-figma hosts, IDN
104
+ * look-alikes, `..`/percent-escaped traversal in the key position, non-https
105
+ * schemes, and over-length input.
106
+ */
107
+ export function parseFigmaTarget(
108
+ input: string,
109
+ defaultSurface: FigmaSurface = 'design'
110
+ ): FigmaTarget {
111
+ const raw = typeof input === 'string' ? input.trim() : '';
112
+ if (!raw) throw new FigmaUrlError('empty Figma URL or file key');
113
+ // Bound the input before any parsing — a URL this long is not a real one, and
114
+ // it keeps every regex below linear on a fixed budget.
115
+ if (raw.length > 2048) throw new FigmaUrlError('Figma URL is too long');
116
+
117
+ // Bare key — the shortest path, and the one that cannot carry a host at all.
118
+ if (FILE_KEY_RE.test(raw)) return { fileKey: raw, surface: defaultSurface };
119
+
120
+ // Anything that isn't a bare key must be a URL. Parse with the platform
121
+ // parser rather than a regex: `URL` resolves userinfo, percent-escapes, IDN
122
+ // and backslash quirks the same way a browser would, so the host we then
123
+ // check is the host a browser would actually connect to. A hand-rolled regex
124
+ // is exactly how `https://api.figma.com@evil.tld/` gets misread.
125
+ let url: URL;
126
+ try {
127
+ url = new URL(raw);
128
+ } catch {
129
+ throw new FigmaUrlError('not a valid Figma URL or file key');
130
+ }
131
+
132
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
133
+ throw new FigmaUrlError('Figma URL must be http(s)');
134
+ }
135
+ // `URL` puts userinfo in `username`/`password`, never in `hostname` — so this
136
+ // check is what makes `https://api.figma.com@evil.tld/…` fail rather than
137
+ // silently resolving to `evil.tld`.
138
+ if (url.username || url.password) {
139
+ throw new FigmaUrlError('Figma URL must not carry credentials');
140
+ }
141
+ if (!isFigmaHost(url.hostname)) {
142
+ throw new FigmaUrlError('not a figma.com URL');
143
+ }
144
+
145
+ // `url.pathname` keeps percent-encoding intact (the parser resolves literal
146
+ // `..` segments but never decodes `%2e%2e`/`%2f`), so validating each segment
147
+ // against a charset that excludes `%` rejects both forms: a resolved `..`
148
+ // cannot produce a key-shaped segment, and an encoded one still carries `%`.
149
+ const segments = url.pathname.split('/').filter((s) => s.length > 0);
150
+ if (segments.length < 2) throw new FigmaUrlError('Figma URL carries no document key');
151
+
152
+ const surface = SURFACE_BY_SEGMENT[segments[0].toLowerCase()];
153
+ if (!surface) throw new FigmaUrlError('unrecognized Figma URL shape');
154
+
155
+ const fileKey = segments[1];
156
+ if (!FILE_KEY_RE.test(fileKey)) throw new FigmaUrlError('Figma document key is not valid');
157
+
158
+ const nodeId = normalizeNodeId(url.searchParams.get('node-id'));
159
+ return nodeId ? { fileKey, nodeId, surface } : { fileKey, surface };
160
+ }
@@ -41,6 +41,7 @@ import { probeSetupReadiness } from './design-setup-readiness.ts';
41
41
  import { type Format, isFormat, isScope, type Scope } from './exporters/index.ts';
42
42
  import { type ExportJobQueue, ExportQueueFullError } from './exporters/jobs.ts';
43
43
  import type { ActiveJsonShape } from './exporters/scope.ts';
44
+ import { createFigmaEndpoints } from './figma/endpoints.ts';
44
45
  import { generatedClipAnalysis } from './footage/schema.ts';
45
46
  import { createFootageStore, FOOTAGE_MAX_BYTES } from './footage-store.ts';
46
47
  import {
@@ -405,6 +406,7 @@ export const READ_ONLY_ALLOWED_WRITES = new Set([
405
406
  // still a gate that is wrong.
406
407
  '/_comments',
407
408
  '/_api/hub/link', // link/unlink ≈ session management (cell allows /auth/logout)
409
+ '/_api/cloud/detach', // unlink — the same session-management class as hub/link
408
410
  '/_api/workspace/sign-in', // signing in is how the role is (re)learned
409
411
  '/_api/workspace/disclosure',
410
412
  '/_api/cloud/signin/start', // account session, not project state
@@ -1110,6 +1112,85 @@ export function createHttp(
1110
1112
  // startCanvasServer) and loopback-Host gated — every route either bears or
1111
1113
  // stores the cloud credential.
1112
1114
  const cloudApi = createCloudEndpoints(ctx);
1115
+ // Figma import (DDR-216). Same dual-allowlist rule as the GitHub + cloud
1116
+ // routes: MAIN-ORIGIN ONLY, plus loopback-Host and same-origin gating on
1117
+ // every one of them — each either stores or spends the user's Figma PAT.
1118
+ const figmaApi = createFigmaEndpoints({
1119
+ // The import itself lives in the CLI helper — ONE implementation, so the
1120
+ // panel and `maude design import-figma` cannot drift apart in what they
1121
+ // sanitize, cap or report. Imported lazily so the studio's boot path never
1122
+ // pulls the import machinery (and its browser-backed SVG lane) into memory
1123
+ // for the 99% of sessions that never import anything.
1124
+ async runImport({ url, mode, dryRun }) {
1125
+ const mod = await import('./bin/_import-figma.mjs');
1126
+ const args = {
1127
+ url,
1128
+ root: ctx.paths.repoRoot,
1129
+ designRootRel: ctx.paths.designRel,
1130
+ dryRun: Boolean(dryRun),
1131
+ };
1132
+ if (mode === 'board') {
1133
+ const r = await mod.importBoard(args);
1134
+ return {
1135
+ summary: {
1136
+ mode,
1137
+ slug: r.slug,
1138
+ strokeCount: r.strokeCount,
1139
+ dispositions: r.report.entries,
1140
+ },
1141
+ };
1142
+ }
1143
+ if (mode === 'frames') {
1144
+ const r = await mod.importFrames(args);
1145
+ return {
1146
+ summary: {
1147
+ mode,
1148
+ frameCount: r.frameCount,
1149
+ written: r.written.map((w: { slug: string }) => w.slug),
1150
+ assets: { resolved: r.resolvedAssets, pending: r.pendingExports },
1151
+ dispositions: r.reports.flatMap((rep: { entries: unknown[] }) => rep.entries),
1152
+ },
1153
+ };
1154
+ }
1155
+ const r = await mod.importTokens(args);
1156
+ return {
1157
+ summary: {
1158
+ mode,
1159
+ source: r.source,
1160
+ count: r.count,
1161
+ path: r.path,
1162
+ dispositions: r.report.entries,
1163
+ },
1164
+ };
1165
+ },
1166
+ // `--explode` from the panel. Same lazy import and same ONE implementation
1167
+ // as the routes above — and note what is NOT here: the raw codegen response
1168
+ // never leaves the verb, so this route returns the same code-owned
1169
+ // accounting an agent would see on stdout (DDR-219 D10).
1170
+ async explode({ canvas, artboard, dryRun, confirmDocument }) {
1171
+ const mod = await import('./bin/_import-figma.mjs');
1172
+ const r = await mod.explodeArtboard({
1173
+ root: ctx.paths.repoRoot,
1174
+ designRootRel: ctx.paths.designRel,
1175
+ canvasRel: canvas,
1176
+ artboardId: artboard,
1177
+ dryRun: Boolean(dryRun),
1178
+ confirmDocument: Boolean(confirmDocument),
1179
+ });
1180
+ return {
1181
+ summary: {
1182
+ canvas: r.canvas,
1183
+ artboard: r.artboardId,
1184
+ route: 'codegen',
1185
+ endpoint: 'local',
1186
+ responseSha256: r.responseSha256,
1187
+ written: r.written,
1188
+ assets: r.assets ?? null,
1189
+ dispositions: r.report.entries,
1190
+ },
1191
+ };
1192
+ },
1193
+ });
1113
1194
  const gitJson = (r: { status: number; json: unknown }) =>
1114
1195
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
1115
1196
 
@@ -2374,6 +2455,18 @@ export function createHttp(
2374
2455
  )
2375
2456
  );
2376
2457
  },
2458
+ '/_api/cloud/detach': async (req: Request) => {
2459
+ // The in-app `maude design unlink` (fix 7, sync RCA 2026-08-10). Same
2460
+ // gates as attach; MAIN ORIGIN ONLY (absent from CANVAS_SAFE_API +
2461
+ // startCanvasServer routes — it rewrites config.json and drops a stored
2462
+ // credential).
2463
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2464
+ if (!sameOriginWrite(req))
2465
+ return new Response('cross-origin write rejected', { status: 403 });
2466
+ if (!isTrustedRequestHost(req))
2467
+ return new Response('local request required', { status: 403 });
2468
+ return gitJson(await cloudApi.detach());
2469
+ },
2377
2470
 
2378
2471
  '/_api/github/identity': async (req: Request) => {
2379
2472
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
@@ -2419,6 +2512,89 @@ export function createHttp(
2419
2512
  return gitJson(await githubApi.clone(body));
2420
2513
  },
2421
2514
 
2515
+ // ── Figma import (DDR-216 D2/D3) ────────────────────────────────────────
2516
+ // MAIN-ORIGIN ONLY: absent from BOTH CANVAS_SAFE_API and startCanvasServer's
2517
+ // `routes` map, so the untrusted canvas origin cannot reach them at all —
2518
+ // a canvas-reachable Figma route would be a token-exfiltration primitive AND
2519
+ // an SSRF primitive at once.
2520
+ //
2521
+ // Allowlist-omission alone proves the WRONG property, though: the main origin
2522
+ // is reachable from any website the user happens to visit while the server is
2523
+ // up. Without the two gates below, a CORS-simple POST from evil.example would
2524
+ // plant an attacker's PAT (`connect`) or spend the user's on an attacker-chosen
2525
+ // file key (`probe`) — opaque response, real side effect. So all three carry
2526
+ // `isTrustedRequestHost` + `sameOriginWrite` + a body cap, and `probe` counts
2527
+ // as a WRITE for gating purposes even though it stores nothing: it spends the
2528
+ // credential and reaches the network. (DDR-216 D3, Round-1 security finding.)
2529
+ '/_api/figma/status': async (req: Request) => {
2530
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2531
+ // `sameOriginRead`, not just `sameOriginWrite`: this is a GET, and browsers
2532
+ // do NOT reliably stamp `Origin` on a simple cross-origin GET — so the
2533
+ // write-side guard cannot see this one coming. Without it, any page the
2534
+ // user visits can probe whether they have Figma connected (a small but
2535
+ // real cross-site state leak, and the same shape as the Task-2.5
2536
+ // audio-search F1 finding this helper was added for).
2537
+ if (!sameOriginRead(req)) return new Response('cross-origin read rejected', { status: 403 });
2538
+ if (!isTrustedRequestHost(req))
2539
+ return new Response('local request required', { status: 403 });
2540
+ return gitJson(figmaApi.status());
2541
+ },
2542
+
2543
+ '/_api/figma/connect': async (req: Request) => {
2544
+ if (req.method !== 'POST' && req.method !== 'DELETE')
2545
+ return new Response('Method not allowed', { status: 405 });
2546
+ if (!sameOriginWrite(req))
2547
+ return new Response('cross-origin write rejected', { status: 403 });
2548
+ if (!isTrustedRequestHost(req))
2549
+ return new Response('local request required', { status: 403 });
2550
+ if (req.method === 'DELETE') return gitJson(figmaApi.disconnect());
2551
+ const body = await readJson<unknown>(req, 8 * 1024);
2552
+ return gitJson(figmaApi.connect(body));
2553
+ },
2554
+
2555
+ '/_api/figma/probe': async (req: Request) => {
2556
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2557
+ if (!sameOriginWrite(req))
2558
+ return new Response('cross-origin write rejected', { status: 403 });
2559
+ if (!isTrustedRequestHost(req))
2560
+ return new Response('local request required', { status: 403 });
2561
+ return gitJson(await figmaApi.probe());
2562
+ },
2563
+
2564
+ // Runs an import. Same triple gate as the other three, and for the same
2565
+ // reasons — it spends the PAT, it reaches the network, and it writes into
2566
+ // the design root. The body is validated into a fixed {mode, url, dryRun}
2567
+ // shape; a caller never gets to choose an output path.
2568
+ '/_api/figma/import': async (req: Request) => {
2569
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2570
+ if (!sameOriginWrite(req))
2571
+ return new Response('cross-origin write rejected', { status: 403 });
2572
+ if (!isTrustedRequestHost(req))
2573
+ return new Response('local request required', { status: 403 });
2574
+ const body = await readJson<unknown>(req, 8 * 1024);
2575
+ return gitJson(await figmaApi.runImport(body));
2576
+ },
2577
+
2578
+ // `--explode` — one artboard, via the LOCAL Dev Mode MCP (DDR-219 D2).
2579
+ //
2580
+ // Same triple gate, and it needs it more than the others: this route reaches
2581
+ // an unauthenticated loopback service that reads whatever Figma document the
2582
+ // user has open, and then WRITES into the design root. A canvas-reachable
2583
+ // version of it would hand the untrusted iframe (DDR-054) a primitive that
2584
+ // reads the user's open design — so it is in NEITHER canvas allowlist, the
2585
+ // same standing rule DDR-088 sets for every privileged route, asserted in
2586
+ // `test/canvas-origin-gate.test.ts` and by the grep test in
2587
+ // `cli/lib/figma-codegen-reachability.test.mjs`.
2588
+ '/_api/figma/explode': async (req: Request) => {
2589
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2590
+ if (!sameOriginWrite(req))
2591
+ return new Response('cross-origin write rejected', { status: 403 });
2592
+ if (!isTrustedRequestHost(req))
2593
+ return new Response('local request required', { status: 403 });
2594
+ const body = await readJson<unknown>(req, 8 * 1024);
2595
+ return gitJson(await figmaApi.explode(body));
2596
+ },
2597
+
2422
2598
  '/_api/github/create-project': async (req: Request) => {
2423
2599
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2424
2600
  if (!sameOriginWrite(req))