@1agh/maude 0.58.0 → 0.58.2

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.
@@ -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. */