@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.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. 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
- /** Upsert the token (+ the vouched role) under `normUrl` + record per-machine trust; mode 0600. */
112
- export function saveHubCredential(normUrl: string, token: string, role?: string): void {
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] = { token, linkedAt: Date.now(), ...(role ? { role } : {}) };
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
- writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
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(path, 0o600);
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 cfg = loadHubsConfig();
114
- const record = cfg.hubs[norm];
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
  }