@1agh/maude 0.58.2 → 0.58.3

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 (105) hide show
  1. package/apps/studio/annotations-bindings.ts +83 -4
  2. package/apps/studio/api.ts +6 -1
  3. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  4. package/apps/studio/bin/_import-asset.mjs +72 -0
  5. package/apps/studio/bin/_import-figma.mjs +1121 -0
  6. package/apps/studio/bin/_video-playwright.mjs +86 -3
  7. package/apps/studio/bin/import-figma.sh +38 -0
  8. package/apps/studio/bin/read-annotations.mjs +11 -1
  9. package/apps/studio/bun.lock +16 -22
  10. package/apps/studio/canvas-edit.ts +29 -5
  11. package/apps/studio/client/app.jsx +44 -1
  12. package/apps/studio/client/export-center.jsx +42 -4
  13. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  14. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  15. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  16. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  17. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  18. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  19. package/apps/studio/client/panels/timeline-parse.js +3 -3
  20. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  21. package/apps/studio/client/styles/4-components.css +134 -0
  22. package/apps/studio/clip-ops.ts +93 -17
  23. package/apps/studio/cloud/endpoints.ts +78 -10
  24. package/apps/studio/cloud/renew.ts +183 -0
  25. package/apps/studio/context.ts +2 -1
  26. package/apps/studio/dist/client.bundle.js +1261 -1261
  27. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  28. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  29. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  30. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  31. package/apps/studio/dist/runtime/remotion.js +12 -12
  32. package/apps/studio/dist/styles.css +1 -1
  33. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  34. package/apps/studio/exporters/_runtime.ts +19 -0
  35. package/apps/studio/exporters/degraded.ts +92 -0
  36. package/apps/studio/exporters/index.ts +5 -0
  37. package/apps/studio/exporters/jobs.ts +19 -0
  38. package/apps/studio/exporters/unsupported-media.ts +170 -0
  39. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  40. package/apps/studio/exporters/video-render-lib.ts +6 -0
  41. package/apps/studio/exporters/video.ts +62 -1
  42. package/apps/studio/figma/assets.test.ts +372 -0
  43. package/apps/studio/figma/assets.ts +398 -0
  44. package/apps/studio/figma/client.test.ts +395 -0
  45. package/apps/studio/figma/client.ts +513 -0
  46. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  47. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  48. package/apps/studio/figma/endpoints.ts +200 -0
  49. package/apps/studio/figma/sanitize.test.ts +256 -0
  50. package/apps/studio/figma/sanitize.ts +315 -0
  51. package/apps/studio/figma/style-map.ts +352 -0
  52. package/apps/studio/figma/to-artboard.test.ts +808 -0
  53. package/apps/studio/figma/to-artboard.ts +701 -0
  54. package/apps/studio/figma/to-render.test.ts +180 -0
  55. package/apps/studio/figma/to-render.ts +306 -0
  56. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  57. package/apps/studio/figma/to-strokes.test.ts +705 -0
  58. package/apps/studio/figma/to-strokes.ts +749 -0
  59. package/apps/studio/figma/to-tokens.test.ts +321 -0
  60. package/apps/studio/figma/to-tokens.ts +305 -0
  61. package/apps/studio/figma/types.ts +539 -0
  62. package/apps/studio/figma/url.test.ts +167 -0
  63. package/apps/studio/figma/url.ts +160 -0
  64. package/apps/studio/http.ts +129 -0
  65. package/apps/studio/sync/asset-push.ts +124 -0
  66. package/apps/studio/sync/connection-state.ts +11 -0
  67. package/apps/studio/sync/hub-link.ts +63 -7
  68. package/apps/studio/sync/hubs-config.ts +31 -3
  69. package/apps/studio/sync/index.ts +276 -26
  70. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  71. package/apps/studio/sync/presentation.ts +45 -1
  72. package/apps/studio/sync/supervisor.ts +5 -1
  73. package/apps/studio/sync/workspace-signin.ts +7 -3
  74. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  75. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  76. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  77. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  78. package/apps/studio/test/clip-addressing.test.ts +6 -1
  79. package/apps/studio/test/clip-ops.test.ts +5 -1
  80. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  81. package/apps/studio/test/cloud-renew.test.ts +205 -0
  82. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  83. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  84. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  85. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  86. package/apps/studio/test/figma-provenance.test.ts +108 -0
  87. package/apps/studio/test/figma-routes.test.ts +294 -0
  88. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  89. package/apps/studio/test/hub-link.test.ts +11 -0
  90. package/apps/studio/test/import-figma.test.ts +479 -0
  91. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  92. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  93. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  94. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  95. package/apps/studio/test/sync-path-pull.test.ts +63 -0
  96. package/apps/studio/test/sync-presentation.test.ts +77 -0
  97. package/apps/studio/test/sync-runtime.test.ts +316 -1
  98. package/apps/studio/test/video-comp.test.ts +23 -1
  99. package/apps/studio/test/workspace-containment.test.ts +1 -0
  100. package/apps/studio/video-comp.tsx +70 -6
  101. package/apps/studio/whats-new.json +27 -0
  102. package/apps/studio/workspace-mode.ts +4 -0
  103. package/cli/commands/design.mjs +8 -0
  104. package/package.json +8 -8
  105. 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,58 @@ 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
+ });
1113
1167
  const gitJson = (r: { status: number; json: unknown }) =>
1114
1168
  Response.json(r.json, { status: r.status, headers: { 'Cache-Control': 'no-store' } });
1115
1169
 
@@ -2374,6 +2428,18 @@ export function createHttp(
2374
2428
  )
2375
2429
  );
2376
2430
  },
2431
+ '/_api/cloud/detach': async (req: Request) => {
2432
+ // The in-app `maude design unlink` (fix 7, sync RCA 2026-08-10). Same
2433
+ // gates as attach; MAIN ORIGIN ONLY (absent from CANVAS_SAFE_API +
2434
+ // startCanvasServer routes — it rewrites config.json and drops a stored
2435
+ // credential).
2436
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2437
+ if (!sameOriginWrite(req))
2438
+ return new Response('cross-origin write rejected', { status: 403 });
2439
+ if (!isTrustedRequestHost(req))
2440
+ return new Response('local request required', { status: 403 });
2441
+ return gitJson(await cloudApi.detach());
2442
+ },
2377
2443
 
2378
2444
  '/_api/github/identity': async (req: Request) => {
2379
2445
  if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
@@ -2419,6 +2485,69 @@ export function createHttp(
2419
2485
  return gitJson(await githubApi.clone(body));
2420
2486
  },
2421
2487
 
2488
+ // ── Figma import (DDR-216 D2/D3) ────────────────────────────────────────
2489
+ // MAIN-ORIGIN ONLY: absent from BOTH CANVAS_SAFE_API and startCanvasServer's
2490
+ // `routes` map, so the untrusted canvas origin cannot reach them at all —
2491
+ // a canvas-reachable Figma route would be a token-exfiltration primitive AND
2492
+ // an SSRF primitive at once.
2493
+ //
2494
+ // Allowlist-omission alone proves the WRONG property, though: the main origin
2495
+ // is reachable from any website the user happens to visit while the server is
2496
+ // up. Without the two gates below, a CORS-simple POST from evil.example would
2497
+ // plant an attacker's PAT (`connect`) or spend the user's on an attacker-chosen
2498
+ // file key (`probe`) — opaque response, real side effect. So all three carry
2499
+ // `isTrustedRequestHost` + `sameOriginWrite` + a body cap, and `probe` counts
2500
+ // as a WRITE for gating purposes even though it stores nothing: it spends the
2501
+ // credential and reaches the network. (DDR-216 D3, Round-1 security finding.)
2502
+ '/_api/figma/status': async (req: Request) => {
2503
+ if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
2504
+ // `sameOriginRead`, not just `sameOriginWrite`: this is a GET, and browsers
2505
+ // do NOT reliably stamp `Origin` on a simple cross-origin GET — so the
2506
+ // write-side guard cannot see this one coming. Without it, any page the
2507
+ // user visits can probe whether they have Figma connected (a small but
2508
+ // real cross-site state leak, and the same shape as the Task-2.5
2509
+ // audio-search F1 finding this helper was added for).
2510
+ if (!sameOriginRead(req)) return new Response('cross-origin read rejected', { status: 403 });
2511
+ if (!isTrustedRequestHost(req))
2512
+ return new Response('local request required', { status: 403 });
2513
+ return gitJson(figmaApi.status());
2514
+ },
2515
+
2516
+ '/_api/figma/connect': async (req: Request) => {
2517
+ if (req.method !== 'POST' && req.method !== 'DELETE')
2518
+ return new Response('Method not allowed', { status: 405 });
2519
+ if (!sameOriginWrite(req))
2520
+ return new Response('cross-origin write rejected', { status: 403 });
2521
+ if (!isTrustedRequestHost(req))
2522
+ return new Response('local request required', { status: 403 });
2523
+ if (req.method === 'DELETE') return gitJson(figmaApi.disconnect());
2524
+ const body = await readJson<unknown>(req, 8 * 1024);
2525
+ return gitJson(figmaApi.connect(body));
2526
+ },
2527
+
2528
+ '/_api/figma/probe': async (req: Request) => {
2529
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2530
+ if (!sameOriginWrite(req))
2531
+ return new Response('cross-origin write rejected', { status: 403 });
2532
+ if (!isTrustedRequestHost(req))
2533
+ return new Response('local request required', { status: 403 });
2534
+ return gitJson(await figmaApi.probe());
2535
+ },
2536
+
2537
+ // Runs an import. Same triple gate as the other three, and for the same
2538
+ // reasons — it spends the PAT, it reaches the network, and it writes into
2539
+ // the design root. The body is validated into a fixed {mode, url, dryRun}
2540
+ // shape; a caller never gets to choose an output path.
2541
+ '/_api/figma/import': async (req: Request) => {
2542
+ if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2543
+ if (!sameOriginWrite(req))
2544
+ return new Response('cross-origin write rejected', { status: 403 });
2545
+ if (!isTrustedRequestHost(req))
2546
+ return new Response('local request required', { status: 403 });
2547
+ const body = await readJson<unknown>(req, 8 * 1024);
2548
+ return gitJson(await figmaApi.runImport(body));
2549
+ },
2550
+
2422
2551
  '/_api/github/create-project': async (req: Request) => {
2423
2552
  if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
2424
2553
  if (!sameOriginWrite(req))
@@ -0,0 +1,124 @@
1
+ // Desktop→cell asset push — DDR-217 (fix 6 of the 2026-08-10 sync RCA).
2
+ //
3
+ // The sync lanes are text-only (`html`/`css`/`meta`/`syncMeta`), so a
4
+ // desktop-linked project's `<designRoot>/assets/*` never reached the cell: its
5
+ // `/assets/` proxy and its studio child both served bytes they did not have —
6
+ // the grey boxes. The desktop is the one peer that HAS the bytes and already
7
+ // holds an authenticated channel to the hub, so it pushes them over the asset
8
+ // route: `HEAD /assets/<key>` to skip what the cloud already holds, then a
9
+ // streamed `PUT /assets/<key>` for the rest. The hub writes into its checkout
10
+ // and mirrors to the bucket (the browser-upload precedent) — see
11
+ // `apps/hub/src/assets.mjs`.
12
+ //
13
+ // The key-shape rules here MIRROR the hub's `ASSET_KEY` (assets.mjs) — a file
14
+ // this pushes but the hub refuses is a wasted upload, and one this skips but
15
+ // the proxy would serve is a broken image. The HUB's validation stays the
16
+ // authoritative gate (each trust boundary validates its own input); this list
17
+ // is the courtesy filter that keeps junk off the wire.
18
+
19
+ import { type Dirent, readdirSync, statSync } from 'node:fs';
20
+ import path from 'node:path';
21
+
22
+ /** One path segment of a pushable key — the hub's `ASSET_KEY` charset. */
23
+ const KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
+
25
+ /** `ASSET_KEY` allows the name plus up to 4 directory segments. */
26
+ const MAX_SEGMENTS = 5;
27
+
28
+ /** Mirror of the sweeper's implausibility bound — a 2 GB file in `assets/` is
29
+ * a mistake, and paying to move it silently is the wrong response. */
30
+ const MAX_PUSH_BYTES = 512 * 1024 * 1024;
31
+
32
+ export interface AssetPushResult {
33
+ pushed: string[];
34
+ skipped: number;
35
+ failed: { key: string; reason: string }[];
36
+ }
37
+
38
+ /** Pushable keys under `<designRoot>/assets/`, relative to it. Missing dir → []. */
39
+ export function listPushableAssets(designRoot: string): string[] {
40
+ const root = path.join(designRoot, 'assets');
41
+ const out: string[] = [];
42
+ const walk = (dir: string, prefix: string, depth: number): void => {
43
+ let entries: Dirent[];
44
+ try {
45
+ entries = readdirSync(dir, { withFileTypes: true });
46
+ } catch {
47
+ return;
48
+ }
49
+ for (const entry of entries) {
50
+ if (!KEY_SEGMENT.test(entry.name)) continue; // dotfiles fail the leading-alnum rule
51
+ const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
52
+ if (entry.isDirectory()) {
53
+ if (depth + 1 < MAX_SEGMENTS) walk(path.join(dir, entry.name), rel, depth + 1);
54
+ } else if (entry.isFile()) {
55
+ try {
56
+ if (statSync(path.join(dir, entry.name)).size > MAX_PUSH_BYTES) continue;
57
+ } catch {
58
+ continue;
59
+ }
60
+ out.push(rel);
61
+ }
62
+ }
63
+ };
64
+ walk(root, '', 1);
65
+ return out.sort();
66
+ }
67
+
68
+ /**
69
+ * Mirror local assets up to the hub. Idempotent and skip-first (one HEAD per
70
+ * asset per boot; upload only on a miss), sequential on purpose — assets run
71
+ * to videos, and saturating the link a fresh sync is also using would starve
72
+ * the handshakes this rides behind. Never throws; a failed upload is retried
73
+ * for free on the next boot.
74
+ */
75
+ export async function pushAssets(opts: {
76
+ designRoot: string;
77
+ hubUrl: string;
78
+ /** Read at call time — silent renewal swaps the credential in place. */
79
+ token: () => string;
80
+ fetchImpl?: typeof fetch;
81
+ log?: Pick<Console, 'log' | 'warn'>;
82
+ }): Promise<AssetPushResult> {
83
+ const { designRoot, hubUrl } = opts;
84
+ const fetchImpl = opts.fetchImpl ?? fetch;
85
+ const log = opts.log ?? console;
86
+ const base = hubUrl.replace(/\/+$/, '');
87
+ const out: AssetPushResult = { pushed: [], skipped: 0, failed: [] };
88
+
89
+ for (const key of listPushableAssets(designRoot)) {
90
+ const url = `${base}/assets/${key}`;
91
+ const headers = { authorization: `Bearer ${opts.token()}` };
92
+ try {
93
+ const head = await fetchImpl(url, { method: 'HEAD', headers });
94
+ if (head.ok) {
95
+ out.skipped += 1;
96
+ continue;
97
+ }
98
+ const put = await fetchImpl(url, {
99
+ method: 'PUT',
100
+ headers,
101
+ body: Bun.file(path.join(designRoot, 'assets', key)),
102
+ });
103
+ if (put.ok) out.pushed.push(key);
104
+ else out.failed.push({ key, reason: `HTTP ${put.status}` });
105
+ } catch (err) {
106
+ out.failed.push({ key, reason: (err as Error).message });
107
+ }
108
+ }
109
+
110
+ if (out.pushed.length > 0) {
111
+ log.log?.(
112
+ `[sync/assets] pushed ${out.pushed.length} asset(s) to ${base} (${out.skipped} already there)`
113
+ );
114
+ }
115
+ if (out.failed.length > 0) {
116
+ log.warn?.(
117
+ `[sync/assets] ${out.failed.length} asset(s) did not reach ${base} (retried next boot): ${out.failed
118
+ .slice(0, 3)
119
+ .map((f) => `${f.key} — ${f.reason}`)
120
+ .join('; ')}`
121
+ );
122
+ }
123
+ return out;
124
+ }
@@ -42,6 +42,14 @@ export interface SyncStatusSnapshot {
42
42
  flash: 'synced' | null;
43
43
  /** ms epoch this snapshot was produced. */
44
44
  updatedAt: number;
45
+ /**
46
+ * ms epoch this link's monitor was created — i.e. when this runtime started
47
+ * trying. The one clock `connecting…` can honestly be measured against:
48
+ * `updatedAt` moves on every emit (an auth-rejection storm refreshes it
49
+ * forever), so "how long has nothing synced" needs a stamp that does NOT
50
+ * reset while nothing is actually working. Absent in pre-existing payloads.
51
+ */
52
+ startedAt?: number;
45
53
  /** DDR-102 — per-doc rollup (additive; absent in pre-DDR-102 payloads). */
46
54
  docs?: { synced: number; pending: number; rejected: number };
47
55
  /** DDR-102 — slugs currently auth-rejected, capped at 20 (see docs.rejected
@@ -130,6 +138,8 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
130
138
  let lastSyncAt: number | null = null;
131
139
  let offlineSince: number | null = null;
132
140
  let flash: 'synced' | null = null;
141
+ /** When this monitor began trying — see `SyncStatusSnapshot.startedAt`. */
142
+ const startedAt = now();
133
143
 
134
144
  let graceTimer: TimerHandle | null = null;
135
145
  let escalateTimer: TimerHandle | null = null;
@@ -172,6 +182,7 @@ export function createConnectionMonitor(opts: ConnectionMonitorOptions = {}): Co
172
182
  offlineSince,
173
183
  flash,
174
184
  updatedAt: now(),
185
+ startedAt,
175
186
  docs,
176
187
  rejectedSlugs,
177
188
  ...(pulled ? { pulled } : {}),