@1agh/maude 0.58.1 → 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/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- 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 +129 -23
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- 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/client/styles/6-acp-chat.css +12 -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 +1491 -1491
- 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/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -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 +755 -32
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- 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/comment-relay-origin-gate.test.ts +117 -0
- package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
- 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/fixtures/mock-acp-agent-wedged.mjs +37 -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-canvas-path.test.ts +200 -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 +465 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +434 -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 +36 -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,329 @@
|
|
|
1
|
+
// Where a synced canvas belongs on disk — the one answer both receivers use.
|
|
2
|
+
//
|
|
3
|
+
// A document name carries a FLATTENED slug (`ui/2026/social/summer-camp.tsx` →
|
|
4
|
+
// `ui-2026-social-summer-camp`). `/`→`-` is not reversible, so neither receiver
|
|
5
|
+
// could reconstruct the folder and both wrote the body flat at the design root.
|
|
6
|
+
// A file there is inside no `canvasGroups` entry, which means the file tree does
|
|
7
|
+
// not list it and `scanCanvases` does not sync it onward: the canvas arrives and
|
|
8
|
+
// is invisible.
|
|
9
|
+
//
|
|
10
|
+
// So the path travels WITH the document, in `syncMeta.path` — an existing,
|
|
11
|
+
// already-synced, never-materialized lane. This module is the receiver's half:
|
|
12
|
+
// it decides whether to believe it.
|
|
13
|
+
//
|
|
14
|
+
// WHY THE RECEIVER CHECKS RATHER THAN TRUSTS. DDR-054 treats hub-pushed content
|
|
15
|
+
// as untrusted, and a path is a strictly more dangerous input than a slug — a
|
|
16
|
+
// slug can only name a file, a path chooses a directory. The load-bearing rule
|
|
17
|
+
// is #7 below: the path must slug BACK to the document carrying it. That makes
|
|
18
|
+
// a hostile path self-defeating, because a path pointing somewhere else no
|
|
19
|
+
// longer addresses this document. The value is not believed because the sender
|
|
20
|
+
// is trusted; it is believed because it was checked against a value the
|
|
21
|
+
// receiver derived independently.
|
|
22
|
+
//
|
|
23
|
+
// A rejected path is never fatal. It degrades to `fallbackCanvasPath`, which is
|
|
24
|
+
// today's behaviour made VISIBLE — flat, but inside a canvas group.
|
|
25
|
+
//
|
|
26
|
+
// ONE COPY, TWO RUNTIMES (the `apps/hub/Dockerfile` rule). The hub imports this
|
|
27
|
+
// file rather than re-typing it in `.mjs`, exactly as it does for
|
|
28
|
+
// `sync/autocommit.ts` and `cloud/mirror.mjs`: re-typing a guarantee is
|
|
29
|
+
// re-typing it without its tests. Hence dependency-free — no `node:path`, no
|
|
30
|
+
// `node:fs`, nothing a plain-Node hub or a bun-compiled sidecar has to resolve.
|
|
31
|
+
// Callers that need real disk resolution pass `join`/`resolve`/`sep` in, the
|
|
32
|
+
// way `pullTargets` already does.
|
|
33
|
+
|
|
34
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
35
|
+
|
|
36
|
+
/** A `canvasGroups[]` entry, as loose as the config actually is. */
|
|
37
|
+
export interface CanvasGroupLike {
|
|
38
|
+
path?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type CanvasPathVerdict = { ok: true; rel: string } | { ok: false; reason: string };
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Cap on a wire path. Generous next to any real project tree and far below the
|
|
45
|
+
* point where a path becomes an amplification vector.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_CANVAS_PATH_LEN = 400;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Cap on path DEPTH, which the length cap does not imply.
|
|
51
|
+
*
|
|
52
|
+
* The hub's `slugFromDocName` accepts any slash-free tail, so a 400-character
|
|
53
|
+
* path can still be 200 single-character components — and the receivers create
|
|
54
|
+
* parent directories with `mkdirSync(recursive: true)`. Eight is past anything
|
|
55
|
+
* a real project nests.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_CANVAS_PATH_DEPTH = 8;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The charset one path component may use.
|
|
61
|
+
*
|
|
62
|
+
* `slugFromDocName`'s explicit-charset style, widened by exactly one character:
|
|
63
|
+
* a space, because canvas filenames legitimately contain them (`Kanban App.tsx`
|
|
64
|
+
* — the slug transform maps whitespace to `_`, so such files exist and sync
|
|
65
|
+
* today). No dot: that is what keeps `.`/`..` and extension smuggling out of
|
|
66
|
+
* every component but the last, which is handled separately.
|
|
67
|
+
*/
|
|
68
|
+
// NOTE the `(?![\s\S])` terminator rather than `$`: in JavaScript `$` ALSO
|
|
69
|
+
// matches immediately before a trailing newline, so `/^[a-z]+$/.test('ui\n')`
|
|
70
|
+
// is true. Rule 1's control-character check catches that today, but these
|
|
71
|
+
// regexes are documented as the charset boundary and must hold on their own.
|
|
72
|
+
const COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9 _-]*(?![\s\S])/;
|
|
73
|
+
|
|
74
|
+
/** The final component: the same charset plus exactly one `.tsx` suffix. */
|
|
75
|
+
const FINAL_COMPONENT = /^[A-Za-z0-9_-][A-Za-z0-9 _-]*\.tsx(?![\s\S])/;
|
|
76
|
+
|
|
77
|
+
/** A component may not end in a space — trailing-space filenames are a mess on
|
|
78
|
+
* every platform and a rename hazard on Windows. */
|
|
79
|
+
const TRAILING_SPACE = / $/;
|
|
80
|
+
|
|
81
|
+
export interface ValidateCanvasPathArgs {
|
|
82
|
+
/** The wire value. Anything at all — this is untrusted input. */
|
|
83
|
+
path: unknown;
|
|
84
|
+
/** The slug of the document that carried it. Rule 7 checks the path against it. */
|
|
85
|
+
slug: string;
|
|
86
|
+
/** Design root, relative to the repo root (`.design`). Rule 7 passes it through. */
|
|
87
|
+
designRel?: string;
|
|
88
|
+
/** Declared groups. A path outside every group is refused (rule 8). */
|
|
89
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
90
|
+
/**
|
|
91
|
+
* Accept a group this project has not declared (rule 8 relaxed to "the first
|
|
92
|
+
* component is group-shaped").
|
|
93
|
+
*
|
|
94
|
+
* ONLY for a genuinely fresh link — a design root with no canvases and no
|
|
95
|
+
* `config.json` of its own. Such a project has declared nothing, so refusing
|
|
96
|
+
* every path against a DEFAULT group list would reject the whole of a project
|
|
97
|
+
* whose author simply calls their group `screens`, and the empty-folder case
|
|
98
|
+
* would arrive invisible. The caller is expected to then WRITE the groups it
|
|
99
|
+
* learned, so this relaxation applies once and never again.
|
|
100
|
+
*
|
|
101
|
+
* It widens which DIRECTORY a path may name; it does not weaken rules 1-7,
|
|
102
|
+
* and rule 7 still ties every path to its own document.
|
|
103
|
+
*/
|
|
104
|
+
allowUndeclaredGroup?: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Believe a `syncMeta.path`, or say why not.
|
|
109
|
+
*
|
|
110
|
+
* Every rule exists because its absence lets a path do something a slug cannot.
|
|
111
|
+
* Ordered cheapest-first, and each returns a reason rather than a boolean so a
|
|
112
|
+
* rejection is debuggable from a log line.
|
|
113
|
+
*/
|
|
114
|
+
export function validateCanvasPath(args: ValidateCanvasPathArgs): CanvasPathVerdict {
|
|
115
|
+
const { path, slug, designRel = '.design', canvasGroups, allowUndeclaredGroup } = args;
|
|
116
|
+
|
|
117
|
+
// 1. A string, bounded, with no NUL or control characters. A NUL truncates
|
|
118
|
+
// the name at the syscall boundary on some platforms, so a path that
|
|
119
|
+
// validates as one thing can create another.
|
|
120
|
+
if (typeof path !== 'string' || path.length === 0) return no('not a non-empty string');
|
|
121
|
+
if (path.length > MAX_CANVAS_PATH_LEN) return no(`longer than ${MAX_CANVAS_PATH_LEN} characters`);
|
|
122
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
|
|
123
|
+
if (/[\u0000-\u001f\u007f]/.test(path)) return no('contains a control character');
|
|
124
|
+
|
|
125
|
+
// 2. Relative. Rejects `/etc/...`, a UNC `\\host\share`, and `C:\...`.
|
|
126
|
+
if (path.startsWith('/')) return no('absolute');
|
|
127
|
+
if (/^[A-Za-z]:/.test(path)) return no('carries a drive letter');
|
|
128
|
+
|
|
129
|
+
// 3. `/` is the only separator. A backslash is a LEGAL filename character on
|
|
130
|
+
// POSIX, so accepting it would let `a\../b` read as one component here and
|
|
131
|
+
// as a traversal on a receiver that normalises separators.
|
|
132
|
+
if (path.includes('\\')) return no('contains a backslash');
|
|
133
|
+
|
|
134
|
+
// 4 + 5. Componentwise. No empty component (`a//b`, a trailing `/`), no `.`
|
|
135
|
+
// or `..`, and nothing outside the charset. Note this runs on the RAW
|
|
136
|
+
// string: `canvasSlugFromRel` below percent-decodes, and a check that
|
|
137
|
+
// ran after decoding could be walked past with `%2e%2e`.
|
|
138
|
+
const parts = path.split('/');
|
|
139
|
+
if (parts.length < 2) return no('not inside a canvas group');
|
|
140
|
+
if (parts.length > MAX_CANVAS_PATH_DEPTH) return no('nested deeper than a project ever is');
|
|
141
|
+
for (let i = 0; i < parts.length; i++) {
|
|
142
|
+
const part = parts[i];
|
|
143
|
+
if (part.length === 0) return no('has an empty path component');
|
|
144
|
+
if (part === '.' || part === '..') return no('has a dot component');
|
|
145
|
+
if (TRAILING_SPACE.test(part)) return no('has a component ending in a space');
|
|
146
|
+
const re = i === parts.length - 1 ? FINAL_COMPONENT : COMPONENT;
|
|
147
|
+
if (!re.test(part)) return no(`component ${i} is outside the canvas charset`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// 6. Covered by FINAL_COMPONENT — stated separately because it is a rule, not
|
|
151
|
+
// an implementation detail: the sync body lane is `.tsx` (Phase 3.6).
|
|
152
|
+
|
|
153
|
+
// 6b. THE ONE PLACE RULE 7 IS NOT A BIJECTION. `canvasSlugFromRel` strips an
|
|
154
|
+
// optional leading `<designRel>/`, so with `designRoot: "mocks"` the path
|
|
155
|
+
// `mocks/ui/card.tsx` slugs to `ui-card` — the same slug as `ui/card.tsx`,
|
|
156
|
+
// one component the check cannot see. Contained either way, but "the path
|
|
157
|
+
// slugs back to its own document" has to be true without an asterisk, so
|
|
158
|
+
// the redundant prefix is refused outright rather than silently stripped.
|
|
159
|
+
const designPrefix = String(designRel ?? '').replace(/^\/+|\/+$/g, '');
|
|
160
|
+
if (designPrefix && (path === designPrefix || path.startsWith(`${designPrefix}/`))) {
|
|
161
|
+
return no('repeats the design-root prefix');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 7. THE ONE THAT MAKES THIS SAFE. The path must slug back to the document
|
|
165
|
+
// that carried it. `canvasSlugFromRel` is IMPORTED, never re-typed: a
|
|
166
|
+
// second copy that drifts turns the whole check into decoration.
|
|
167
|
+
const derived = canvasSlugFromRel(path, designRel);
|
|
168
|
+
if (derived !== String(slug ?? '').toLowerCase()) {
|
|
169
|
+
return no(`slugs to "${derived}", not to this document`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 8. Inside a declared canvas group. Containment against the design root
|
|
173
|
+
// follows from rules 2-4 (relative, no `..`, no backslash), and this is
|
|
174
|
+
// the stronger statement anyway: a path outside every group is a path the
|
|
175
|
+
// tree would not list, which is the failure this whole module exists for.
|
|
176
|
+
// Callers keep their own resolve()-based check as well — belt and braces
|
|
177
|
+
// at a create.
|
|
178
|
+
// Case-INSENSITIVE, because rule 7 above lowercases: `ui/CARD.tsx` satisfies
|
|
179
|
+
// rule 7 against slug `ui-card`, and a case-sensitive group check would then
|
|
180
|
+
// accept or refuse the same document depending on the peer's filesystem.
|
|
181
|
+
const groups = normalizedGroups(canvasGroups);
|
|
182
|
+
const lower = path.toLowerCase();
|
|
183
|
+
if (!groups.some((g) => lower.startsWith(`${g.toLowerCase()}/`))) {
|
|
184
|
+
// Rules 4-5 already proved every component is group-shaped, so under the
|
|
185
|
+
// fresh-link relaxation there is nothing further to check — the path is
|
|
186
|
+
// inside SOME group, just not one this project has heard of yet.
|
|
187
|
+
if (!allowUndeclaredGroup) return no('outside every declared canvas group');
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { ok: true, rel: path };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Where a canvas goes when no path arrived, or the one that did was refused.
|
|
195
|
+
*
|
|
196
|
+
* Flat — a slug cannot be un-flattened without guessing — but placed INSIDE a
|
|
197
|
+
* canvas group wherever that is possible, because the design root is not inside
|
|
198
|
+
* one: the file tree and `scanCanvases` enumerate `canvasGroups`, so the old
|
|
199
|
+
* design-root fallback produced a file nobody could see and that never synced
|
|
200
|
+
* onward. A flat file in `ui/` is untidy; a flat file at the root is lost.
|
|
201
|
+
*
|
|
202
|
+
* THE CONSTRAINT THAT SHAPES THIS. Moving a canvas is already a new document
|
|
203
|
+
* (the slug derives from the path), so the fallback may not put the body
|
|
204
|
+
* anywhere that re-slugs. Writing `ui-legacy` to `ui/ui-legacy.tsx` would slug
|
|
205
|
+
* to `ui-ui-legacy` on the receiver's next scan: a SECOND document, syncing the
|
|
206
|
+
* same bytes under a different name, with the original left orphaned on the hub
|
|
207
|
+
* — a duplicate is strictly worse than an untidy file.
|
|
208
|
+
*
|
|
209
|
+
* So the group is chosen by the slug's own prefix, which is where the slug came
|
|
210
|
+
* from in the first place: `ui-legacy` → `ui/legacy.tsx`, which slugs back to
|
|
211
|
+
* `ui-legacy`. Visible AND the same document. A slug matching no declared group
|
|
212
|
+
* (a canvas that was already outside every group at its author) keeps today's
|
|
213
|
+
* design-root behaviour — identity preserved, still invisible, and no worse
|
|
214
|
+
* than before. That case cannot be fixed without either a path or a rename, and
|
|
215
|
+
* a path is exactly what `syncMeta.path` supplies.
|
|
216
|
+
*/
|
|
217
|
+
export function fallbackCanvasPath(
|
|
218
|
+
slug: string,
|
|
219
|
+
canvasGroups?: readonly CanvasGroupLike[]
|
|
220
|
+
): string {
|
|
221
|
+
const safeSlug = sanitizeSlug(slug);
|
|
222
|
+
// LONGEST match wins: `ui/social` is a better home than `ui` for
|
|
223
|
+
// `ui-social-x`, and declaration order should not decide that. No other
|
|
224
|
+
// tie-break is needed — two groups of equal length that both prefix one slug
|
|
225
|
+
// are the same group.
|
|
226
|
+
const matching = normalizedGroups(canvasGroups)
|
|
227
|
+
.filter((g) => safeSlug.startsWith(`${prefixOf(g)}-`))
|
|
228
|
+
.sort((a, b) => b.length - a.length);
|
|
229
|
+
for (const group of matching) {
|
|
230
|
+
const rest = safeSlug.slice(prefixOf(group).length + 1);
|
|
231
|
+
if (rest.length > 0) return `${group}/${rest}.tsx`;
|
|
232
|
+
}
|
|
233
|
+
return `${safeSlug}.tsx`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The slug prefix a group path contributes (`ui/social` → `ui-social`). */
|
|
237
|
+
function prefixOf(group: string): string {
|
|
238
|
+
return group.replace(/\//g, '-').replace(/\s+/g, '_').toLowerCase();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* A slug reduced to something that can only ever be ONE filename.
|
|
243
|
+
*
|
|
244
|
+
* Both `slugFromDocName`s already constrain the charset upstream, so this is
|
|
245
|
+
* the second line rather than the first — but it is the line standing between
|
|
246
|
+
* a bad slug and a directory of the sender's choosing, and it is cheap.
|
|
247
|
+
*/
|
|
248
|
+
function sanitizeSlug(slug: unknown): string {
|
|
249
|
+
const s = String(slug ?? '')
|
|
250
|
+
.toLowerCase()
|
|
251
|
+
.replace(/[^a-z0-9 ._-]/g, '-')
|
|
252
|
+
// No `..` — contained either way once the separators are gone, but a
|
|
253
|
+
// filename containing it invites a later reader to normalise it.
|
|
254
|
+
.replace(/\.{2,}/g, '-')
|
|
255
|
+
// No dotfile, and no leading separator-ish character.
|
|
256
|
+
.replace(/^[.\s-]+/, '');
|
|
257
|
+
return s.length > 0 ? s : 'canvas';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Declared group paths, normalised and stripped of anything that escapes. */
|
|
261
|
+
function normalizedGroups(canvasGroups?: readonly CanvasGroupLike[]): string[] {
|
|
262
|
+
const out: string[] = [];
|
|
263
|
+
for (const g of canvasGroups ?? []) {
|
|
264
|
+
const p = normalizeGroup(g?.path);
|
|
265
|
+
if (p && !out.includes(p)) out.push(p);
|
|
266
|
+
}
|
|
267
|
+
return out.length > 0 ? out : ['system', 'ui'];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* One group path, or null when it is not usable as a containment prefix.
|
|
272
|
+
*
|
|
273
|
+
* The same shape as `isContainedRel` (context.ts) and deliberately not a call
|
|
274
|
+
* to it — that function is in the dev-server's config module, which pulls
|
|
275
|
+
* `node:path`, and this file has to load in the hub's plain-Node runtime.
|
|
276
|
+
* Stricter here, which is the safe direction: a group path that is not a plain
|
|
277
|
+
* relative directory name is dropped rather than clamped.
|
|
278
|
+
*/
|
|
279
|
+
function normalizeGroup(raw: unknown): string | null {
|
|
280
|
+
if (typeof raw !== 'string') return null;
|
|
281
|
+
const p = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
282
|
+
if (!p) return null;
|
|
283
|
+
if (/^[A-Za-z]:/.test(p)) return null;
|
|
284
|
+
const parts = p.split('/');
|
|
285
|
+
for (const part of parts) {
|
|
286
|
+
if (!part || part === '.' || part === '..') return null;
|
|
287
|
+
if (!COMPONENT.test(part)) return null;
|
|
288
|
+
}
|
|
289
|
+
return p;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function no(reason: string): CanvasPathVerdict {
|
|
293
|
+
return { ok: false, reason };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The receiver's whole decision in one call: believe the wire path, or fall
|
|
298
|
+
* back — and either way return a design-root-relative body path.
|
|
299
|
+
*
|
|
300
|
+
* Both receivers call THIS rather than composing the two above, so "what does a
|
|
301
|
+
* refused path do" has one answer instead of two that can drift.
|
|
302
|
+
*/
|
|
303
|
+
export function resolveCanvasBodyRel(args: {
|
|
304
|
+
path: unknown;
|
|
305
|
+
slug: string;
|
|
306
|
+
designRel?: string;
|
|
307
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
308
|
+
allowUndeclaredGroup?: boolean;
|
|
309
|
+
/** Called with the reason when a PRESENT path is refused. Absent is not a
|
|
310
|
+
* refusal — an older peer omits the field and that is the normal case. */
|
|
311
|
+
onRefused?: (reason: string) => void;
|
|
312
|
+
}): { rel: string; fromPath: boolean } {
|
|
313
|
+
const { path, slug, designRel, canvasGroups, allowUndeclaredGroup, onRefused } = args;
|
|
314
|
+
if (path !== undefined && path !== null) {
|
|
315
|
+
const verdict = validateCanvasPath({
|
|
316
|
+
path,
|
|
317
|
+
slug,
|
|
318
|
+
designRel,
|
|
319
|
+
canvasGroups,
|
|
320
|
+
allowUndeclaredGroup,
|
|
321
|
+
});
|
|
322
|
+
if (verdict.ok) return { rel: verdict.rel, fromPath: true };
|
|
323
|
+
onRefused?.(verdict.reason);
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
rel: fallbackCanvasPath(slug, canvasGroups),
|
|
327
|
+
fromPath: false,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -368,6 +368,48 @@ export function seededByFromDoc(doc: Y.Doc): number | null {
|
|
|
368
368
|
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
369
369
|
}
|
|
370
370
|
|
|
371
|
+
/**
|
|
372
|
+
* Record WHERE this canvas lives, design-root-relative (`syncMeta.path`).
|
|
373
|
+
*
|
|
374
|
+
* The document name carries only the flattened slug, and `/`→`-` is not
|
|
375
|
+
* reversible — so a receiver that has never seen this canvas cannot know which
|
|
376
|
+
* folder it belongs in, and wrote it flat. This is the lane that fixes that.
|
|
377
|
+
*
|
|
378
|
+
* `syncMeta` is the right home rather than a new one: it is already per
|
|
379
|
+
* document, already synced, already optional on the wire (an older peer simply
|
|
380
|
+
* omits it), and — the part that matters here — NEVER MATERIALIZED TO DISK.
|
|
381
|
+
* `.meta.json` could not carry this: a canvas's own path is per-machine-ish
|
|
382
|
+
* bookkeeping about the sync lane, and putting it in the sidecar would both
|
|
383
|
+
* commit a redundant fact to the tenant's repo and put it under `.meta.json`'s
|
|
384
|
+
* shared-subset rules (META_LOCAL_KEYS governs a DIFFERENT lane and would not
|
|
385
|
+
* keep it off disk).
|
|
386
|
+
*
|
|
387
|
+
* Only ever called with a path the caller DERIVED from a real local file — not
|
|
388
|
+
* with a value read off the wire. Re-stamping something a receiver refused
|
|
389
|
+
* would launder an invalid path into the project on the next hop.
|
|
390
|
+
*
|
|
391
|
+
* Idempotent: an unchanged value writes nothing, so this can sit in the same
|
|
392
|
+
* transaction as every body apply without churning the wire.
|
|
393
|
+
*/
|
|
394
|
+
export function stampCanvasPath(doc: Y.Doc, rel: string, origin?: unknown): boolean {
|
|
395
|
+
const next = String(rel ?? '').replace(/\\/g, '/');
|
|
396
|
+
if (!next) return false;
|
|
397
|
+
const map = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta);
|
|
398
|
+
if (map.get('path') === next) return false;
|
|
399
|
+
doc.transact(() => {
|
|
400
|
+
map.set('path', next);
|
|
401
|
+
}, origin);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** The doc-side path stamp, or null when no peer ever stamped one (an older
|
|
406
|
+
* peer, or a document nobody has opened from a real local file). UNTRUSTED —
|
|
407
|
+
* every caller must put it through `validateCanvasPath` (canvas-path.ts). */
|
|
408
|
+
export function canvasPathFromDoc(doc: Y.Doc): string | null {
|
|
409
|
+
const v = doc.getMap<unknown>(Y_SYNC_TYPES.syncMeta).get('path');
|
|
410
|
+
return typeof v === 'string' && v.length > 0 ? v : null;
|
|
411
|
+
}
|
|
412
|
+
|
|
371
413
|
/* ---------------------------------------------------------------- css */
|
|
372
414
|
|
|
373
415
|
/** The synced canvas CSS string held in the doc, or null when unset/empty. */
|
|
@@ -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 } : {}),
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// phase-9's hub model expects private hosts), so we do NOT block private IPs; the safety
|
|
23
23
|
// is the loopback caller gate + no reflection + no token on the probe.
|
|
24
24
|
|
|
25
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { dirname } from 'node:path';
|
|
27
27
|
|
|
28
28
|
import { hubsConfigPath, normalizeUrl } from './hubs-config.ts';
|
|
@@ -35,7 +35,7 @@ export interface HubLinkResult {
|
|
|
35
35
|
const HUB_PROBE_TIMEOUT_MS = 4000;
|
|
36
36
|
|
|
37
37
|
interface HubsFile {
|
|
38
|
-
hubs: Record<string, { token: string; linkedAt: number }>;
|
|
38
|
+
hubs: Record<string, { token: string; linkedAt: number; role?: string; expiresAt?: number }>;
|
|
39
39
|
trusted?: string[];
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -108,8 +108,20 @@ async function probeHealth(url: string): Promise<{ ok: boolean; version?: string
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
111
|
+
/**
|
|
112
|
+
* Upsert the token (+ the vouched role + expiry) under `normUrl` + record
|
|
113
|
+
* per-machine trust; mode 0600.
|
|
114
|
+
*
|
|
115
|
+
* The upsert REPLACES the record, so a caller that knows the role/expiry must
|
|
116
|
+
* pass them again or they are dropped — the silent-renewal path reads the old
|
|
117
|
+
* record first and carries the role forward for exactly this reason.
|
|
118
|
+
*/
|
|
119
|
+
export function saveHubCredential(
|
|
120
|
+
normUrl: string,
|
|
121
|
+
token: string,
|
|
122
|
+
role?: string,
|
|
123
|
+
expiresAt?: number
|
|
124
|
+
): void {
|
|
113
125
|
const path = hubsConfigPath();
|
|
114
126
|
const dir = dirname(path);
|
|
115
127
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
@@ -123,15 +135,59 @@ export function saveHubCredential(normUrl: string, token: string, role?: string)
|
|
|
123
135
|
/* malformed → start fresh rather than throw */
|
|
124
136
|
}
|
|
125
137
|
}
|
|
126
|
-
cfg.hubs[normUrl] = {
|
|
138
|
+
cfg.hubs[normUrl] = {
|
|
139
|
+
token,
|
|
140
|
+
linkedAt: Date.now(),
|
|
141
|
+
...(role ? { role } : {}),
|
|
142
|
+
...(typeof expiresAt === 'number' && Number.isFinite(expiresAt) ? { expiresAt } : {}),
|
|
143
|
+
};
|
|
127
144
|
if (!Array.isArray(cfg.trusted)) cfg.trusted = [];
|
|
128
145
|
if (!cfg.trusted.includes(normUrl)) cfg.trusted.push(normUrl);
|
|
129
|
-
|
|
146
|
+
// F3 (2026-08-10 review) — atomic temp-then-rename. This write used to run
|
|
147
|
+
// only when a human pressed Connect; silent renewal now fires it on a timer,
|
|
148
|
+
// several times a minute under a rejection burst. A truncating in-place
|
|
149
|
+
// writeFileSync that is interrupted (crash / kill / full disk) leaves a
|
|
150
|
+
// half-written file — and since it holds EVERY hub credential on the machine,
|
|
151
|
+
// that drops them all. rename(2) is atomic on POSIX: a reader sees the whole
|
|
152
|
+
// old file or the whole new one, never a torn one.
|
|
153
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
154
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
155
|
+
try {
|
|
156
|
+
chmodSync(tmp, 0o600);
|
|
157
|
+
} catch {
|
|
158
|
+
/* windows / read-only fs — best effort */
|
|
159
|
+
}
|
|
160
|
+
renameSync(tmp, path);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Forget the credential + per-machine trust for `normUrl` — the unlink half of
|
|
165
|
+
* `saveHubCredential`. Idempotent (a missing entry is simply done), and atomic
|
|
166
|
+
* for the same reason the save is: the file holds EVERY hub credential on the
|
|
167
|
+
* machine, so a torn write drops them all.
|
|
168
|
+
*/
|
|
169
|
+
export function deleteHubCredential(normUrl: string): void {
|
|
170
|
+
const path = hubsConfigPath();
|
|
171
|
+
if (!existsSync(path)) return;
|
|
172
|
+
let cfg: HubsFile;
|
|
173
|
+
try {
|
|
174
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
175
|
+
if (!parsed || typeof parsed.hubs !== 'object' || parsed.hubs === null) return;
|
|
176
|
+
cfg = parsed as HubsFile;
|
|
177
|
+
} catch {
|
|
178
|
+
return; // malformed → nothing recoverable to forget
|
|
179
|
+
}
|
|
180
|
+
if (!(normUrl in cfg.hubs) && !cfg.trusted?.includes(normUrl)) return;
|
|
181
|
+
delete cfg.hubs[normUrl];
|
|
182
|
+
if (Array.isArray(cfg.trusted)) cfg.trusted = cfg.trusted.filter((u) => u !== normUrl);
|
|
183
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
184
|
+
writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
130
185
|
try {
|
|
131
|
-
chmodSync(
|
|
186
|
+
chmodSync(tmp, 0o600);
|
|
132
187
|
} catch {
|
|
133
188
|
/* windows / read-only fs — best effort */
|
|
134
189
|
}
|
|
190
|
+
renameSync(tmp, path);
|
|
135
191
|
}
|
|
136
192
|
|
|
137
193
|
export const __testing = { probeHealth };
|
|
@@ -23,6 +23,20 @@ export interface HubRecord {
|
|
|
23
23
|
* write-capable, and nobody is demoted by an upgrade.
|
|
24
24
|
*/
|
|
25
25
|
role?: string;
|
|
26
|
+
/**
|
|
27
|
+
* When this credential dies (ms epoch), as the workspace reported at mint.
|
|
28
|
+
*
|
|
29
|
+
* A cell session expires WITH the 12 h project token that minted it (Phase
|
|
30
|
+
* 23 B2 — that cap is the revocation window and must stay). What was
|
|
31
|
+
* missing is anyone LOOKING at the deadline: the token was saved, the
|
|
32
|
+
* expiry discarded, and ≤ 12 h later all sync died into a permanent
|
|
33
|
+
* `connecting…`. Stored so the runtime can renew BEFORE it, silently.
|
|
34
|
+
*
|
|
35
|
+
* Absent on self-hosted hubs (their tokens may not expire) and on every
|
|
36
|
+
* credential written before this shipped — both mean "no scheduled
|
|
37
|
+
* renewal", which is exactly the old behaviour.
|
|
38
|
+
*/
|
|
39
|
+
expiresAt?: number;
|
|
26
40
|
}
|
|
27
41
|
|
|
28
42
|
export interface HubsConfig {
|
|
@@ -40,6 +54,16 @@ export function hubsConfigPath(): string {
|
|
|
40
54
|
/** Normalize a hub URL — trim trailing slash, lower-case scheme + host. */
|
|
41
55
|
export function normalizeUrl(url: string): string {
|
|
42
56
|
const u = new URL(url);
|
|
57
|
+
// Reject embedded credentials outright (2026-08-10 review, claim-a residual).
|
|
58
|
+
// `URL.toString()` PRESERVES `user:pass@` — the renewal lane is safe only
|
|
59
|
+
// because it fetches the normalized string and requires it to equal a URL the
|
|
60
|
+
// cloud itself listed. A `https://evil@proj.cloud.maude.sh` config would
|
|
61
|
+
// survive normalization intact; refusing it here means such a value can never
|
|
62
|
+
// be stored or matched, so a later "fetch hubUrl directly" edit can't become
|
|
63
|
+
// credential exfiltration. A real hub URL never carries userinfo.
|
|
64
|
+
if (u.username || u.password) {
|
|
65
|
+
throw new Error('hub URL must not contain embedded credentials');
|
|
66
|
+
}
|
|
43
67
|
u.protocol = u.protocol.toLowerCase();
|
|
44
68
|
u.hostname = u.hostname.toLowerCase();
|
|
45
69
|
let str = u.toString();
|
|
@@ -108,11 +132,15 @@ export function isHubReadOnly(url: string): boolean {
|
|
|
108
132
|
|
|
109
133
|
/** Look up a token for `url`. Returns null when no entry exists. */
|
|
110
134
|
export function getHubToken(url: string): string | null {
|
|
135
|
+
return getHubRecord(url)?.token ?? null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The whole stored record for `url` (token + role + expiresAt), or null. */
|
|
139
|
+
export function getHubRecord(url: string): HubRecord | null {
|
|
111
140
|
try {
|
|
112
141
|
const norm = normalizeUrl(url);
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
return record ? record.token : null;
|
|
142
|
+
const record = loadHubsConfig().hubs[norm];
|
|
143
|
+
return record && typeof record.token === 'string' ? record : null;
|
|
116
144
|
} catch {
|
|
117
145
|
return null;
|
|
118
146
|
}
|