@1agh/maude 0.58.1 → 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.
- package/apps/studio/.ai/cache/_stats.json +1 -1
- package/apps/studio/acp/bridge.ts +165 -15
- package/apps/studio/client/app.jsx +85 -22
- package/apps/studio/client/panels/ChatPanel.jsx +25 -2
- package/apps/studio/client/styles/6-acp-chat.css +12 -0
- package/apps/studio/dist/client.bundle.js +1146 -1146
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/sync/canvas-path.ts +329 -0
- package/apps/studio/sync/codec.ts +42 -0
- package/apps/studio/sync/index.ts +490 -17
- package/apps/studio/sync/projection.ts +11 -1
- package/apps/studio/sync/remote-docs.ts +122 -15
- package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
- 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/fixtures/mock-acp-agent-wedged.mjs +37 -0
- package/apps/studio/test/sync-canvas-path.test.ts +200 -0
- package/apps/studio/test/sync-path-pull.test.ts +402 -0
- package/apps/studio/test/sync-remote-docs.test.ts +55 -3
- package/apps/studio/test/sync-runtime.test.ts +118 -0
- package/apps/studio/whats-new.json +9 -0
- package/package.json +8 -8
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// The traversal corpus, and the rule that makes the whole thing safe.
|
|
2
|
+
//
|
|
3
|
+
// `syncMeta.path` is hub-supplied input that chooses a DIRECTORY. Every case
|
|
4
|
+
// below is a way a path could try to be something other than "a canvas in this
|
|
5
|
+
// project", plus the one rule that closes the class rather than the instance:
|
|
6
|
+
// the path must slug back to the document carrying it.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, it } from 'bun:test';
|
|
9
|
+
|
|
10
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
11
|
+
import {
|
|
12
|
+
fallbackCanvasPath,
|
|
13
|
+
MAX_CANVAS_PATH_LEN,
|
|
14
|
+
resolveCanvasBodyRel,
|
|
15
|
+
validateCanvasPath,
|
|
16
|
+
} from '../sync/canvas-path.ts';
|
|
17
|
+
|
|
18
|
+
const GROUPS = [{ path: 'system' }, { path: 'ui' }];
|
|
19
|
+
|
|
20
|
+
/** Validate a path AGAINST THE SLUG IT WOULD REALLY CARRY, so a rejection is
|
|
21
|
+
* attributable to the rule under test and not to rule 7 firing first. */
|
|
22
|
+
function check(path: unknown, slug: string) {
|
|
23
|
+
return validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('validateCanvasPath — what it accepts', () => {
|
|
27
|
+
it('accepts a genuinely nested canvas', () => {
|
|
28
|
+
const v = check('ui/2026/social/summer-camp.tsx', 'ui-2026-social-summer-camp');
|
|
29
|
+
expect(v).toEqual({ ok: true, rel: 'ui/2026/social/summer-camp.tsx' });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('accepts a canvas directly in a group', () => {
|
|
33
|
+
expect(check('ui/Card.tsx', 'ui-card').ok).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('accepts the design-system group', () => {
|
|
37
|
+
expect(check('system/preview/logo.tsx', 'system-preview-logo').ok).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('accepts a filename with a space — they exist and they sync today', () => {
|
|
41
|
+
// The slug transform maps whitespace to `_`, so `Kanban App.tsx` is a real
|
|
42
|
+
// canvas with a real document. Refusing it here would silently relocate a
|
|
43
|
+
// file that works today.
|
|
44
|
+
expect(check('ui/Kanban App.tsx', 'ui-kanban_app').ok).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('is case-insensitive about the slug, like the slug transform', () => {
|
|
48
|
+
expect(check('ui/Card.tsx', 'UI-CARD').ok).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('validateCanvasPath — the traversal corpus', () => {
|
|
53
|
+
const corpus: Array<[string, unknown, string]> = [
|
|
54
|
+
['parent traversal', '../../etc/passwd.tsx', 'anything'],
|
|
55
|
+
['traversal inside a group', 'ui/../../../tmp/x.tsx', 'anything'],
|
|
56
|
+
['a bare dot component', 'ui/./Card.tsx', 'ui-card'],
|
|
57
|
+
['absolute posix', '/etc/cron.d/x.tsx', 'anything'],
|
|
58
|
+
['windows drive', 'C:\\Windows\\x.tsx', 'anything'],
|
|
59
|
+
['UNC share', '\\\\host\\share\\x.tsx', 'anything'],
|
|
60
|
+
['a backslash smuggling a separator', 'ui\\..\\..\\x.tsx', 'anything'],
|
|
61
|
+
['an empty component', 'ui//Card.tsx', 'ui-card'],
|
|
62
|
+
['a trailing slash', 'ui/Card.tsx/', 'ui-card'],
|
|
63
|
+
['a NUL byte', 'ui/Card.tsx\u0000.png', 'ui-card'],
|
|
64
|
+
['a newline', 'ui/Ca\nrd.tsx', 'ui-card'],
|
|
65
|
+
['percent-encoded traversal', 'ui/%2e%2e/%2e%2e/x.tsx', 'anything'],
|
|
66
|
+
['a unicode dot lookalike as a component', 'ui/\u2024\u2024/x.tsx', 'anything'],
|
|
67
|
+
['a bidi override in a component', 'ui/Ca\u202erd.tsx', 'ui-card'],
|
|
68
|
+
['not a .tsx', 'ui/Card.html', 'ui-card'],
|
|
69
|
+
['no extension at all', 'ui/Card', 'ui-card'],
|
|
70
|
+
['an extra extension', 'ui/Card.tsx.png', 'ui-card'],
|
|
71
|
+
['a dotfile', 'ui/.env.tsx', 'anything'],
|
|
72
|
+
['at the design root, in no group', 'ui-card.tsx', 'ui-card'],
|
|
73
|
+
['outside every declared group', 'assets/Card.tsx', 'assets-card'],
|
|
74
|
+
['not a string', 42, 'anything'],
|
|
75
|
+
['an object', { toString: () => 'ui/Card.tsx' }, 'ui-card'],
|
|
76
|
+
['the empty string', '', 'anything'],
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
for (const [label, path, slug] of corpus) {
|
|
80
|
+
it(`refuses ${label}`, () => {
|
|
81
|
+
const v = validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
|
|
82
|
+
expect(v.ok).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it('refuses a path longer than the cap', () => {
|
|
87
|
+
const long = `ui/${'a'.repeat(MAX_CANVAS_PATH_LEN)}.tsx`;
|
|
88
|
+
expect(validateCanvasPath({ path: long, slug: 'x', canvasGroups: GROUPS }).ok).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('rule 7 — a path that points elsewhere no longer addresses this document', () => {
|
|
93
|
+
it('refuses a well-formed path belonging to a DIFFERENT canvas', () => {
|
|
94
|
+
// The one that matters. Every rule above refuses a MALFORMED path; this
|
|
95
|
+
// refuses a perfectly legal one that simply is not this document's. Without
|
|
96
|
+
// it, any peer could overwrite any other canvas in the project by pushing a
|
|
97
|
+
// body under its own document name and someone else's path.
|
|
98
|
+
const v = check('ui/secrets/credentials.tsx', 'ui-card');
|
|
99
|
+
expect(v.ok).toBe(false);
|
|
100
|
+
if (!v.ok) expect(v.reason).toContain('slugs to');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('refuses a path that only differs in a folder boundary', () => {
|
|
104
|
+
// `ui/a-b.tsx` and `ui/a/b.tsx` slug identically (`ui-a-b`) — so this pair
|
|
105
|
+
// is ACCEPTED by rule 7 on purpose: the collision is the slug's, it predates
|
|
106
|
+
// this feature, and `admitCanvases` already refuses to sync both files.
|
|
107
|
+
expect(check('ui/a/b.tsx', 'ui-a-b').ok).toBe(true);
|
|
108
|
+
expect(check('ui/a-b.tsx', 'ui-a-b').ok).toBe(true);
|
|
109
|
+
// What is NOT accepted is a path whose slug simply differs.
|
|
110
|
+
expect(check('ui/a/c.tsx', 'ui-a-b').ok).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('fallbackCanvasPath — visible, and still the same document', () => {
|
|
115
|
+
it('places an un-pathed canvas inside the group its slug came from', () => {
|
|
116
|
+
expect(fallbackCanvasPath('ui-legacy', GROUPS)).toBe('ui/legacy.tsx');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('the result slugs BACK to the document — a fallback must not fork it', () => {
|
|
120
|
+
// The property, not the string. `ui/ui-legacy.tsx` would be visible and
|
|
121
|
+
// would slug to `ui-ui-legacy`: a second document syncing the same bytes,
|
|
122
|
+
// with the original orphaned on the hub.
|
|
123
|
+
for (const slug of ['ui-legacy', 'ui-2026-social-summer-camp', 'system-preview-logo']) {
|
|
124
|
+
expect(canvasSlugFromRel(fallbackCanvasPath(slug, GROUPS), '.design')).toBe(slug);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('picks the group the slug actually came from, not the first one', () => {
|
|
129
|
+
const groups = [{ path: 'ui' }, { path: 'screens' }];
|
|
130
|
+
expect(fallbackCanvasPath('screens-x', groups)).toBe('screens/x.tsx');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('prefers the LONGEST matching group over declaration order', () => {
|
|
134
|
+
const groups = [{ path: 'ui' }, { path: 'ui/social' }];
|
|
135
|
+
expect(fallbackCanvasPath('ui-social-x', groups)).toBe('ui/social/x.tsx');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('keeps the design root when no declared group matches the slug', () => {
|
|
139
|
+
// Today's behaviour, deliberately: it is invisible, but it is the SAME
|
|
140
|
+
// document. A canvas that was outside every group at its author cannot be
|
|
141
|
+
// placed inside one without renaming it — which is what a path is for.
|
|
142
|
+
expect(fallbackCanvasPath('welcome', GROUPS)).toBe('welcome.tsx');
|
|
143
|
+
expect(fallbackCanvasPath('x', [])).toBe('x.tsx');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('drops a group path that escapes the design root', () => {
|
|
147
|
+
expect(fallbackCanvasPath('etc-x', [{ path: '../../etc' }])).toBe('etc-x.tsx');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('never produces a separator, a dotfile or a `..` from the slug', () => {
|
|
151
|
+
// The slug is charset-constrained upstream (`slugFromDocName`), so this is
|
|
152
|
+
// the second line rather than the first — but it is the line that stands
|
|
153
|
+
// between a bad slug and a directory of the sender's choosing.
|
|
154
|
+
for (const hostile of ['../../evil', '/etc/passwd', '..', '.', 'a\\b', '']) {
|
|
155
|
+
const rel = fallbackCanvasPath(hostile, GROUPS);
|
|
156
|
+
expect(rel).not.toContain('/');
|
|
157
|
+
expect(rel).not.toContain('\\');
|
|
158
|
+
expect(rel).not.toContain('..');
|
|
159
|
+
expect(rel.startsWith('.')).toBe(false);
|
|
160
|
+
expect(rel.endsWith('.tsx')).toBe(true);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('resolveCanvasBodyRel — one answer for "what does a refused path do"', () => {
|
|
166
|
+
it('uses the path when it validates', () => {
|
|
167
|
+
expect(
|
|
168
|
+
resolveCanvasBodyRel({
|
|
169
|
+
path: 'ui/2026/social/summer-camp.tsx',
|
|
170
|
+
slug: 'ui-2026-social-summer-camp',
|
|
171
|
+
designRel: '.design',
|
|
172
|
+
canvasGroups: GROUPS,
|
|
173
|
+
})
|
|
174
|
+
).toEqual({ rel: 'ui/2026/social/summer-camp.tsx', fromPath: true });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('falls back — never throws the canvas away — and says why', () => {
|
|
178
|
+
const reasons: string[] = [];
|
|
179
|
+
const out = resolveCanvasBodyRel({
|
|
180
|
+
path: '../../etc/passwd.tsx',
|
|
181
|
+
slug: 'ui-card',
|
|
182
|
+
canvasGroups: GROUPS,
|
|
183
|
+
onRefused: (r) => reasons.push(r),
|
|
184
|
+
});
|
|
185
|
+
expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
|
|
186
|
+
expect(reasons.length).toBe(1);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('an ABSENT path is not a refusal — an older peer omits it', () => {
|
|
190
|
+
const reasons: string[] = [];
|
|
191
|
+
const out = resolveCanvasBodyRel({
|
|
192
|
+
path: undefined,
|
|
193
|
+
slug: 'ui-card',
|
|
194
|
+
canvasGroups: GROUPS,
|
|
195
|
+
onRefused: (r) => reasons.push(r),
|
|
196
|
+
});
|
|
197
|
+
expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
|
|
198
|
+
expect(reasons).toEqual([]);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// Pulling a project into an EMPTY folder, through the real sync runtime.
|
|
2
|
+
//
|
|
3
|
+
// The unit tests around `canvas-path.ts` prove the rules; `apps/hub/test/`
|
|
4
|
+
// proves the two receivers. This proves the wiring between them on the desktop
|
|
5
|
+
// side — that the path is read from the DOCUMENT (not the listing, which cannot
|
|
6
|
+
// carry it), that the agent is built around the corrected path rather than the
|
|
7
|
+
// provisional one, that the canvas is written where the tree will find it, and
|
|
8
|
+
// that a folder with no `config.json` ends up with one describing what arrived.
|
|
9
|
+
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
11
|
+
import {
|
|
12
|
+
chmodSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
mkdirSync,
|
|
15
|
+
mkdtempSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
symlinkSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
} from 'node:fs';
|
|
21
|
+
import { tmpdir } from 'node:os';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { Awareness } from 'y-protocols/awareness';
|
|
24
|
+
import * as Y from 'yjs';
|
|
25
|
+
|
|
26
|
+
import type { Context, DevServerConfig } from '../context.ts';
|
|
27
|
+
import { createBus } from '../context.ts';
|
|
28
|
+
import { createSyncRuntime, type SyncProvider } from '../sync/index.ts';
|
|
29
|
+
|
|
30
|
+
const URL_ = 'https://hub.example.com';
|
|
31
|
+
const NESTED_REL = 'ui/2026/social/summer-camp.tsx';
|
|
32
|
+
const NESTED_SLUG = 'ui-2026-social-summer-camp';
|
|
33
|
+
const BODY = 'export default () => <main>summer camp</main>;\n';
|
|
34
|
+
|
|
35
|
+
let dir: string;
|
|
36
|
+
let cfgPathEnv: string | undefined;
|
|
37
|
+
let realFetch: typeof fetch;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
dir = mkdtempSync(join(tmpdir(), 'sync-path-pull-'));
|
|
41
|
+
cfgPathEnv = process.env.HUBS_CONFIG_PATH;
|
|
42
|
+
process.env.HUBS_CONFIG_PATH = join(dir, 'hubs.json');
|
|
43
|
+
writeFileSync(
|
|
44
|
+
process.env.HUBS_CONFIG_PATH,
|
|
45
|
+
JSON.stringify({ hubs: { [URL_]: { token: 'mau_test', linkedAt: 1 } } })
|
|
46
|
+
);
|
|
47
|
+
chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
|
|
48
|
+
realFetch = globalThis.fetch;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
globalThis.fetch = realFetch;
|
|
53
|
+
if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
|
|
54
|
+
else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
|
|
55
|
+
rmSync(dir, { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
/** The hub's `GET /api/documents` — names and byte counts, never a path. */
|
|
59
|
+
function hubListing(documents: Array<{ name: string; bytes: number }>): void {
|
|
60
|
+
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
|
61
|
+
if (String(input).endsWith('/api/documents')) {
|
|
62
|
+
return new Response(JSON.stringify({ documents }), {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: { 'content-type': 'application/json' },
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
return new Response('nope', { status: 404 });
|
|
68
|
+
}) as typeof fetch;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function makeCtx(canvasGroups?: DevServerConfig['canvasGroups']): Context {
|
|
72
|
+
const designRoot = join(dir, 'design');
|
|
73
|
+
mkdirSync(join(designRoot, '_comments'), { recursive: true });
|
|
74
|
+
return {
|
|
75
|
+
canvasOrigin: 'http://canvas.localhost:1234',
|
|
76
|
+
cfg: {
|
|
77
|
+
name: 'test',
|
|
78
|
+
projectLabel: null,
|
|
79
|
+
designRoot: 'design',
|
|
80
|
+
canvasGroups: canvasGroups ?? [
|
|
81
|
+
{ label: 'Design system', path: 'system' },
|
|
82
|
+
{ label: 'Canvases', path: 'ui' },
|
|
83
|
+
],
|
|
84
|
+
rootClass: 'app',
|
|
85
|
+
themeDefault: 'dark',
|
|
86
|
+
tokensCssRel: 'system/colors.css',
|
|
87
|
+
teamAccentDefault: null,
|
|
88
|
+
handoffTargets: [],
|
|
89
|
+
newCanvasDir: 'ui',
|
|
90
|
+
newComponentDir: 'ui/components',
|
|
91
|
+
linkedHub: { url: URL_, linkedAt: 1 },
|
|
92
|
+
_source: 'defaults',
|
|
93
|
+
},
|
|
94
|
+
projectLabel: 'test',
|
|
95
|
+
paths: {
|
|
96
|
+
repoRoot: dir,
|
|
97
|
+
designRel: 'design',
|
|
98
|
+
designRoot,
|
|
99
|
+
serverInfoFile: join(designRoot, '_server.json'),
|
|
100
|
+
activeFile: join(designRoot, '_active.json'),
|
|
101
|
+
commentsDir: join(designRoot, '_comments'),
|
|
102
|
+
canvasStateDir: join(designRoot, '_canvas-state'),
|
|
103
|
+
historyDir: join(designRoot, '_history'),
|
|
104
|
+
tokensUrlRel: 'design/system/colors.css',
|
|
105
|
+
systemDirRel: 'system',
|
|
106
|
+
},
|
|
107
|
+
bus: createBus(),
|
|
108
|
+
} as unknown as Context;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A provider whose document ALREADY holds what the hub holds — the body plus
|
|
113
|
+
* whatever `syncMeta.path` the peer that authored it stamped. That is the shape
|
|
114
|
+
* a real pull sees at `onceSynced`, and the reason the target cannot be decided
|
|
115
|
+
* from the listing.
|
|
116
|
+
*/
|
|
117
|
+
function hubDocProviderFactory(
|
|
118
|
+
seed: Record<string, { body: string; path?: string; css?: string }>,
|
|
119
|
+
docs?: Map<string, Y.Doc>
|
|
120
|
+
) {
|
|
121
|
+
return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
|
|
122
|
+
const doc = args.document ?? new Y.Doc();
|
|
123
|
+
const slug = args.documentName.split('/').pop() as string;
|
|
124
|
+
const entry = seed[slug];
|
|
125
|
+
if (entry) {
|
|
126
|
+
doc.transact(() => {
|
|
127
|
+
doc.getText('html').insert(0, entry.body);
|
|
128
|
+
if (entry.path) doc.getMap('syncMeta').set('path', entry.path);
|
|
129
|
+
if (entry.css) doc.getText('css').insert(0, entry.css);
|
|
130
|
+
}, 'hub');
|
|
131
|
+
}
|
|
132
|
+
docs?.set(slug, doc);
|
|
133
|
+
return {
|
|
134
|
+
document: doc,
|
|
135
|
+
awareness: new Awareness(doc),
|
|
136
|
+
async onceSynced() {},
|
|
137
|
+
destroy() {},
|
|
138
|
+
};
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
describe('a peer with an empty design root pulls the project down whole', () => {
|
|
143
|
+
test('the canvas lands at the path its author gave it', async () => {
|
|
144
|
+
hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
|
|
145
|
+
const ctx = makeCtx();
|
|
146
|
+
const runtime = createSyncRuntime(ctx, {
|
|
147
|
+
providerFactory: hubDocProviderFactory({
|
|
148
|
+
[NESTED_SLUG]: { body: BODY, path: NESTED_REL },
|
|
149
|
+
}),
|
|
150
|
+
});
|
|
151
|
+
await runtime?.start();
|
|
152
|
+
await runtime?.stop();
|
|
153
|
+
|
|
154
|
+
const wanted = join(ctx.paths.designRoot, NESTED_REL);
|
|
155
|
+
expect(existsSync(wanted)).toBe(true);
|
|
156
|
+
expect(readFileSync(wanted, 'utf8')).toBe(BODY);
|
|
157
|
+
// And NOT flat at the design root, where nothing would list it.
|
|
158
|
+
expect(existsSync(join(ctx.paths.designRoot, `${NESTED_SLUG}.tsx`))).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('a document with NO path still arrives, inside a canvas group', async () => {
|
|
162
|
+
hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
|
|
163
|
+
const ctx = makeCtx();
|
|
164
|
+
const runtime = createSyncRuntime(ctx, {
|
|
165
|
+
providerFactory: hubDocProviderFactory({
|
|
166
|
+
'ui-legacy': { body: 'export default () => null;\n' },
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
await runtime?.start();
|
|
170
|
+
await runtime?.stop();
|
|
171
|
+
|
|
172
|
+
// `ui/legacy.tsx`, not `ui/ui-legacy.tsx` — the latter would be visible and
|
|
173
|
+
// would slug to a DIFFERENT document.
|
|
174
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a hostile path is refused and the canvas still arrives', async () => {
|
|
178
|
+
hubListing([{ name: 'ws/acme/main/ui-legacy', bytes: 10 }]);
|
|
179
|
+
const ctx = makeCtx();
|
|
180
|
+
const runtime = createSyncRuntime(ctx, {
|
|
181
|
+
providerFactory: hubDocProviderFactory({
|
|
182
|
+
'ui-legacy': { body: 'x\n', path: '../../../../tmp/pwned.tsx' },
|
|
183
|
+
}),
|
|
184
|
+
});
|
|
185
|
+
await runtime?.start();
|
|
186
|
+
await runtime?.stop();
|
|
187
|
+
|
|
188
|
+
expect(existsSync(join(ctx.paths.designRoot, 'ui', 'legacy.tsx'))).toBe(true);
|
|
189
|
+
expect(existsSync(join(dir, '..', 'tmp', 'pwned.tsx'))).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('a fresh link accepts the author’s OWN group, and writes it down', async () => {
|
|
193
|
+
// `config.json` is not part of the sync lane, so a bare folder runs on the
|
|
194
|
+
// DEFAULT groups. Without the fresh-link relaxation a project whose author
|
|
195
|
+
// calls their group `screens` would have every path refused and arrive
|
|
196
|
+
// invisible — the empty-folder case, in the shape it actually takes.
|
|
197
|
+
hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
|
|
198
|
+
const ctx = makeCtx();
|
|
199
|
+
const runtime = createSyncRuntime(ctx, {
|
|
200
|
+
providerFactory: hubDocProviderFactory({
|
|
201
|
+
'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
|
|
202
|
+
}),
|
|
203
|
+
});
|
|
204
|
+
await runtime?.start();
|
|
205
|
+
await runtime?.stop();
|
|
206
|
+
|
|
207
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(true);
|
|
208
|
+
// …and the project now declares itself, so the next boot needs no relaxation.
|
|
209
|
+
const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
|
|
210
|
+
expect(cfg.canvasGroups.map((g: { path: string }) => g.path)).toContain('screens');
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('a project that already declared itself is NEVER overwritten', async () => {
|
|
214
|
+
const ctx = makeCtx();
|
|
215
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
216
|
+
const cfgFile = join(ctx.paths.designRoot, 'config.json');
|
|
217
|
+
const mine = `${JSON.stringify({ name: 'mine', canvasGroups: [{ label: 'UI', path: 'ui' }] })}\n`;
|
|
218
|
+
writeFileSync(cfgFile, mine);
|
|
219
|
+
hubListing([{ name: 'ws/acme/main/screens-home', bytes: 10 }]);
|
|
220
|
+
|
|
221
|
+
const runtime = createSyncRuntime(ctx, {
|
|
222
|
+
providerFactory: hubDocProviderFactory({
|
|
223
|
+
'screens-home': { body: 'x\n', path: 'screens/home.tsx' },
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
await runtime?.start();
|
|
227
|
+
await runtime?.stop();
|
|
228
|
+
|
|
229
|
+
expect(readFileSync(cfgFile, 'utf8')).toBe(mine);
|
|
230
|
+
// The undeclared group is refused, so the canvas falls back — still arriving.
|
|
231
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'home.tsx'))).toBe(false);
|
|
232
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens-home.tsx'))).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("rule 7 governs a path's IDENTITY, not what already lives there", () => {
|
|
237
|
+
test('a pulled canvas never lands on a file that already exists locally', async () => {
|
|
238
|
+
// `scanCanvases` omits a canvas whose `.meta.json` says `syncable: false` —
|
|
239
|
+
// a security opt-out a hub must not be able to flip. Omitted means absent
|
|
240
|
+
// from `localCanvases`, which means the hub's document for that slug is
|
|
241
|
+
// classified hub-ONLY and pulled. Before the path lane that was benign: the
|
|
242
|
+
// body landed flat at the design root, loaded by nothing. Honouring a remote
|
|
243
|
+
// path would land it on the real file.
|
|
244
|
+
const ctx = makeCtx();
|
|
245
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
246
|
+
const guarded = join(ctx.paths.designRoot, 'ui', 'Card.tsx');
|
|
247
|
+
writeFileSync(guarded, 'export default () => <b>MINE</b>;\n');
|
|
248
|
+
writeFileSync(
|
|
249
|
+
join(ctx.paths.designRoot, 'ui', 'Card.meta.json'),
|
|
250
|
+
JSON.stringify({ syncable: false })
|
|
251
|
+
);
|
|
252
|
+
hubListing([{ name: 'ws/acme/main/ui-card', bytes: 10 }]);
|
|
253
|
+
|
|
254
|
+
const docs = new Map<string, Y.Doc>();
|
|
255
|
+
const runtime = createSyncRuntime(ctx, {
|
|
256
|
+
providerFactory: hubDocProviderFactory(
|
|
257
|
+
{ 'ui-card': { body: 'export default () => <b>HUB</b>;\n', path: 'ui/Card.tsx' } },
|
|
258
|
+
docs
|
|
259
|
+
),
|
|
260
|
+
});
|
|
261
|
+
await runtime?.start();
|
|
262
|
+
|
|
263
|
+
// The boot moment is not the whole story — the provider now OWNS a path, so
|
|
264
|
+
// every later hub edit flows to it. Push one, the way a hub actually would.
|
|
265
|
+
const doc = docs.get('ui-card');
|
|
266
|
+
if (doc) {
|
|
267
|
+
const text = doc.getText('html');
|
|
268
|
+
doc.transact(() => {
|
|
269
|
+
text.delete(0, text.length);
|
|
270
|
+
text.insert(0, 'export default () => <b>PWNED</b>;\n');
|
|
271
|
+
}, 'hub');
|
|
272
|
+
}
|
|
273
|
+
await new Promise((r) => setTimeout(r, 250));
|
|
274
|
+
await runtime?.stop();
|
|
275
|
+
|
|
276
|
+
expect(readFileSync(guarded, 'utf8')).toContain('MINE');
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('a pulled canvas cannot overwrite the served token stylesheet', async () => {
|
|
280
|
+
// `system` is a DEFAULT canvas group and the `.css` sibling is derived from
|
|
281
|
+
// the body path, so `system/colors_and_type.tsx` satisfies every rule and
|
|
282
|
+
// writes its css lane exactly where `tokensCssRel` is served from.
|
|
283
|
+
const ctx = makeCtx();
|
|
284
|
+
mkdirSync(join(ctx.paths.designRoot, 'system'), { recursive: true });
|
|
285
|
+
const tokens = join(ctx.paths.designRoot, 'system', 'colors.css');
|
|
286
|
+
writeFileSync(tokens, ':root{--bg-0:#fff}\n');
|
|
287
|
+
hubListing([{ name: 'ws/acme/main/system-colors', bytes: 10 }]);
|
|
288
|
+
|
|
289
|
+
const runtime = createSyncRuntime(ctx, {
|
|
290
|
+
providerFactory: hubDocProviderFactory({
|
|
291
|
+
'system-colors': {
|
|
292
|
+
body: 'x\n',
|
|
293
|
+
path: 'system/colors.tsx',
|
|
294
|
+
// The lane that does the damage — it is written to the body path's
|
|
295
|
+
// `.css` sibling, which here IS the served `tokensCssRel`.
|
|
296
|
+
css: ':root{--bg-0:url("https://evil.example/?x")}\n',
|
|
297
|
+
},
|
|
298
|
+
}),
|
|
299
|
+
});
|
|
300
|
+
await runtime?.start();
|
|
301
|
+
await runtime?.stop();
|
|
302
|
+
|
|
303
|
+
expect(readFileSync(tokens, 'utf8')).toBe(':root{--bg-0:#fff}\n');
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('containment is checked THROUGH symlinks, which resolve() cannot see', async () => {
|
|
307
|
+
const ctx = makeCtx();
|
|
308
|
+
const outside = mkdtempSync(join(tmpdir(), 'sync-path-outside-'));
|
|
309
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
310
|
+
symlinkSync(outside, join(ctx.paths.designRoot, 'ui', 'escape'), 'dir');
|
|
311
|
+
hubListing([{ name: 'ws/acme/main/ui-escape-pwned', bytes: 10 }]);
|
|
312
|
+
|
|
313
|
+
const runtime = createSyncRuntime(ctx, {
|
|
314
|
+
providerFactory: hubDocProviderFactory({
|
|
315
|
+
'ui-escape-pwned': { body: 'x\n', path: 'ui/escape/pwned.tsx' },
|
|
316
|
+
}),
|
|
317
|
+
});
|
|
318
|
+
await runtime?.start();
|
|
319
|
+
await runtime?.stop();
|
|
320
|
+
|
|
321
|
+
expect(existsSync(join(outside, 'pwned.tsx'))).toBe(false);
|
|
322
|
+
rmSync(outside, { recursive: true, force: true });
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe('the untrusted markers name the path the body actually lands at', () => {
|
|
327
|
+
test('a nested pulled canvas is marked at its REAL path, not the provisional one', async () => {
|
|
328
|
+
// The markers are computed from the descriptor set BEFORE any document has
|
|
329
|
+
// synced — so every pulled entry is still the fallback — and the descriptors
|
|
330
|
+
// are then mutated in place by `relocatePulled`. Marking once left
|
|
331
|
+
// `_untrusted/INDEX.json` and the `.claudeignore` block naming a file that
|
|
332
|
+
// is never created, while the genuinely hub-pushed body sat elsewhere,
|
|
333
|
+
// listed nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
|
|
334
|
+
hubListing([{ name: `ws/acme/main/${NESTED_SLUG}`, bytes: BODY.length }]);
|
|
335
|
+
const ctx = makeCtx();
|
|
336
|
+
const runtime = createSyncRuntime(ctx, {
|
|
337
|
+
providerFactory: hubDocProviderFactory({
|
|
338
|
+
[NESTED_SLUG]: { body: BODY, path: NESTED_REL },
|
|
339
|
+
}),
|
|
340
|
+
});
|
|
341
|
+
await runtime?.start();
|
|
342
|
+
await runtime?.stop();
|
|
343
|
+
|
|
344
|
+
const index = readFileSync(join(ctx.paths.designRoot, '_untrusted', 'INDEX.json'), 'utf8');
|
|
345
|
+
expect(index).toContain('ui/2026/social/summer-camp.tsx');
|
|
346
|
+
// The provisional fallback was never created, so naming it is naming nothing.
|
|
347
|
+
expect(index).not.toContain('ui/2026-social-summer-camp.tsx');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
describe('the fresh-link relaxation closes behind itself', () => {
|
|
352
|
+
test('only ONE undeclared group is ever learned', async () => {
|
|
353
|
+
// `freshLink` was a const, so after the first config was written every
|
|
354
|
+
// FURTHER undeclared group was accepted and appended — the relaxation
|
|
355
|
+
// perpetuating itself, and a hub free to plant directories all session.
|
|
356
|
+
hubListing([
|
|
357
|
+
{ name: 'ws/acme/main/screens-a', bytes: 10 },
|
|
358
|
+
{ name: 'ws/acme/main/vendor-b', bytes: 10 },
|
|
359
|
+
]);
|
|
360
|
+
const ctx = makeCtx();
|
|
361
|
+
const runtime = createSyncRuntime(ctx, {
|
|
362
|
+
providerFactory: hubDocProviderFactory({
|
|
363
|
+
'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
|
|
364
|
+
'vendor-b': { body: 'x\n', path: 'vendor/b.tsx' },
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
await runtime?.start();
|
|
368
|
+
await runtime?.stop();
|
|
369
|
+
|
|
370
|
+
const cfg = JSON.parse(readFileSync(join(ctx.paths.designRoot, 'config.json'), 'utf8'));
|
|
371
|
+
const learned = cfg.canvasGroups
|
|
372
|
+
.map((g: { path: string }) => g.path)
|
|
373
|
+
.filter((p: string) => p !== 'system' && p !== 'ui');
|
|
374
|
+
expect(learned.length).toBe(1);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test('a design root with work in it is NOT a fresh link', async () => {
|
|
378
|
+
// `localCanvases.length === 0` is a fact about the SCAN, which walks only
|
|
379
|
+
// declared groups and applies the syncable + sandbox gates — so a project
|
|
380
|
+
// with real work scans to zero for several innocent reasons, and a hub must
|
|
381
|
+
// not get to author a config.json into it.
|
|
382
|
+
const ctx = makeCtx();
|
|
383
|
+
mkdirSync(join(ctx.paths.designRoot, 'ui'), { recursive: true });
|
|
384
|
+
writeFileSync(join(ctx.paths.designRoot, 'ui', 'Existing.tsx'), 'x\n');
|
|
385
|
+
writeFileSync(
|
|
386
|
+
join(ctx.paths.designRoot, 'ui', 'Existing.meta.json'),
|
|
387
|
+
JSON.stringify({ syncable: false })
|
|
388
|
+
);
|
|
389
|
+
hubListing([{ name: 'ws/acme/main/screens-a', bytes: 10 }]);
|
|
390
|
+
|
|
391
|
+
const runtime = createSyncRuntime(ctx, {
|
|
392
|
+
providerFactory: hubDocProviderFactory({
|
|
393
|
+
'screens-a': { body: 'x\n', path: 'screens/a.tsx' },
|
|
394
|
+
}),
|
|
395
|
+
});
|
|
396
|
+
await runtime?.start();
|
|
397
|
+
await runtime?.stop();
|
|
398
|
+
|
|
399
|
+
expect(existsSync(join(ctx.paths.designRoot, 'config.json'))).toBe(false);
|
|
400
|
+
expect(existsSync(join(ctx.paths.designRoot, 'screens', 'a.tsx'))).toBe(false);
|
|
401
|
+
});
|
|
402
|
+
});
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// on the hub and no local counter could ever show it.
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
import { canvasSlugFromRel } from '../canvas-slug.ts';
|
|
8
9
|
|
|
9
10
|
import {
|
|
10
11
|
describeRemoteDiff,
|
|
@@ -135,7 +136,11 @@ describe('pulling hub-only documents down', () => {
|
|
|
135
136
|
expect(slugFromDocName('ws/acme/main/ui-welcome')).toBe('ui-welcome');
|
|
136
137
|
});
|
|
137
138
|
|
|
138
|
-
test('lands hub-only canvases
|
|
139
|
+
test('lands hub-only canvases where the tree can SEE them', () => {
|
|
140
|
+
// Flat, because a slug cannot be un-flattened — but inside the canvas group
|
|
141
|
+
// the slug came from, not at the design root. The tree and `scanCanvases`
|
|
142
|
+
// enumerate `canvasGroups`, so the old design-root target produced a file
|
|
143
|
+
// nobody could see and that never synced onward.
|
|
139
144
|
const targets = pullTargets(
|
|
140
145
|
[
|
|
141
146
|
{ name: 'ui-welcome', bytes: 1 },
|
|
@@ -147,12 +152,59 @@ describe('pulling hub-only documents down', () => {
|
|
|
147
152
|
'/'
|
|
148
153
|
);
|
|
149
154
|
expect(targets.map((t) => t.bodyAbs)).toEqual([
|
|
150
|
-
'/p/.design/ui
|
|
151
|
-
'/p/.design/ui
|
|
155
|
+
'/p/.design/ui/welcome.tsx',
|
|
156
|
+
'/p/.design/ui/home.tsx',
|
|
152
157
|
]);
|
|
158
|
+
// …and each still slugs back to the document it came from. `ui/ui-welcome.tsx`
|
|
159
|
+
// would be visible AND a different document.
|
|
160
|
+
expect(canvasSlugFromRel('ui/welcome.tsx', '.design')).toBe('ui-welcome');
|
|
153
161
|
// The wire name is kept — the provider must open the document the HUB has,
|
|
154
162
|
// not a name re-derived from the local path.
|
|
155
163
|
expect(targets[1].docName).toBe('ws/acme/main/ui-home');
|
|
164
|
+
// No path arrived, so nothing was believed.
|
|
165
|
+
expect(targets.every((t) => t.fromPath === false)).toBe(true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('uses the path a document carries, once it is known', () => {
|
|
169
|
+
// The listing cannot supply this — it carries names and byte counts only.
|
|
170
|
+
// The runtime re-resolves per document after that document has synced;
|
|
171
|
+
// `pathFor` is that hop, made testable.
|
|
172
|
+
const targets = pullTargets(
|
|
173
|
+
[{ name: 'ws/acme/main/ui-2026-social-summer-camp', bytes: 1 }],
|
|
174
|
+
'/p/.design',
|
|
175
|
+
join,
|
|
176
|
+
resolve,
|
|
177
|
+
'/',
|
|
178
|
+
{
|
|
179
|
+
designRel: '.design',
|
|
180
|
+
canvasGroups: [{ path: 'system' }, { path: 'ui' }],
|
|
181
|
+
pathFor: () => 'ui/2026/social/summer-camp.tsx',
|
|
182
|
+
}
|
|
183
|
+
);
|
|
184
|
+
expect(targets[0].bodyAbs).toBe('/p/.design/ui/2026/social/summer-camp.tsx');
|
|
185
|
+
expect(targets[0].fromPath).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a refused path degrades to the fallback and says why', () => {
|
|
189
|
+
const refusals: string[] = [];
|
|
190
|
+
const targets = pullTargets(
|
|
191
|
+
[{ name: 'ui-welcome', bytes: 1 }],
|
|
192
|
+
'/p/.design',
|
|
193
|
+
join,
|
|
194
|
+
resolve,
|
|
195
|
+
'/',
|
|
196
|
+
{
|
|
197
|
+
designRel: '.design',
|
|
198
|
+
canvasGroups: [{ path: 'ui' }],
|
|
199
|
+
// A perfectly well-formed path that simply is not this document's —
|
|
200
|
+
// the case rule 7 exists for.
|
|
201
|
+
pathFor: () => 'ui/somebody-elses-canvas.tsx',
|
|
202
|
+
onRefused: (slug, reason) => refusals.push(`${slug}: ${reason}`),
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
expect(targets[0].bodyAbs).toBe('/p/.design/ui/welcome.tsx');
|
|
206
|
+
expect(targets[0].fromPath).toBe(false);
|
|
207
|
+
expect(refusals.length).toBe(1);
|
|
156
208
|
});
|
|
157
209
|
|
|
158
210
|
test('a document NAME can never write outside the design root', () => {
|