@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.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- 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/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -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 +1261 -1261
- 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 +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -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 +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -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 +306 -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 +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- 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 +13 -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-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -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 +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -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-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/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -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,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 } : {}),
|