@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/annotations-layer.tsx +49 -15
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +90 -0
- package/apps/studio/bin/_import-figma.mjs +1775 -0
- package/apps/studio/bin/_perf-probe-safari.mjs +332 -0
- package/apps/studio/bin/_perf-probe.mjs +228 -0
- package/apps/studio/bin/_perf-shared.mjs +345 -0
- package/apps/studio/bin/_video-playwright.mjs +103 -7
- package/apps/studio/bin/import-figma.sh +47 -0
- package/apps/studio/bin/perf.sh +228 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bin/smoke.sh +49 -5
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/canvas-lib.tsx +148 -6
- package/apps/studio/client/app.jsx +196 -38
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/SyncPanel.jsx +229 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +31 -3
- package/apps/studio/client/panels/timeline-comp-target.js +101 -0
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +37 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1231 -1231
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +35 -6
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +72 -1
- package/apps/studio/figma/assets.test.ts +464 -0
- package/apps/studio/figma/assets.ts +452 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/codegen-client.test.ts +276 -0
- package/apps/studio/figma/codegen-client.ts +509 -0
- package/apps/studio/figma/codegen-fonts.test.ts +103 -0
- package/apps/studio/figma/codegen-fonts.ts +195 -0
- package/apps/studio/figma/codegen-values.test.ts +179 -0
- package/apps/studio/figma/codegen-values.ts +270 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +273 -0
- package/apps/studio/figma/fig-decode.test.ts +702 -0
- package/apps/studio/figma/fig-decode.ts +617 -0
- package/apps/studio/figma/fig-kiwi.ts +410 -0
- package/apps/studio/figma/fig-zip.ts +270 -0
- package/apps/studio/figma/from-codegen.test.ts +408 -0
- package/apps/studio/figma/from-codegen.ts +1103 -0
- package/apps/studio/figma/sanitize.test.ts +325 -0
- package/apps/studio/figma/sanitize.ts +407 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/tailwind-map.test.ts +142 -0
- package/apps/studio/figma/tailwind-map.ts +545 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +328 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +544 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +176 -0
- package/apps/studio/sync/asset-push.ts +432 -0
- package/apps/studio/sync/connection-state.ts +82 -3
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +286 -27
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/status.ts +18 -0
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +17 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-explode.test.ts +438 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/fixtures/perf-canvas.mjs +201 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +667 -0
- package/apps/studio/test/sync-asset-push.test.ts +567 -0
- package/apps/studio/test/sync-connection-state.test.ts +79 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-panel-surface.test.ts +90 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/sync-status.test.ts +28 -0
- package/apps/studio/test/timeline-comp-target.test.ts +139 -0
- package/apps/studio/test/video-comp.test.ts +104 -2
- package/apps/studio/test/video-encode-lib.test.ts +63 -0
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/use-artboard-drag.tsx +37 -3
- package/apps/studio/video-comp.tsx +121 -6
- package/apps/studio/whats-new.json +98 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +15 -0
- package/cli/commands/kg.mjs +8 -1
- package/cli/commands/kg.test.mjs +24 -0
- package/cli/lib/figma-codegen-reachability.test.mjs +104 -0
- package/cli/lib/figma-import-controls.test.mjs +70 -0
- package/package.json +8 -8
- 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
|
+
}
|
package/apps/studio/http.ts
CHANGED
|
@@ -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))
|