@1agh/maude 0.53.2 → 0.54.0
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/api.ts +13 -7
- package/apps/studio/bin/screenshot.sh +43 -3
- package/apps/studio/build.ts +37 -3
- package/apps/studio/client/app.jsx +208 -25
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
- package/apps/studio/client/styles/4-components.css +40 -0
- package/apps/studio/cloud-build.ts +208 -0
- package/apps/studio/collab/room.ts +43 -7
- package/apps/studio/context.ts +12 -5
- package/apps/studio/dist/client.bundle.js +1007 -1007
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/repo-lock.ts +305 -0
- package/apps/studio/git/service.ts +171 -21
- package/apps/studio/http.ts +145 -12
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/server.ts +114 -17
- package/apps/studio/session-scope.ts +101 -0
- package/apps/studio/sync/autocommit.ts +84 -46
- package/apps/studio/sync/index.ts +20 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +8 -0
- package/apps/studio/test/repo-concurrency.test.ts +146 -0
- package/apps/studio/test/repo-lock.test.ts +350 -0
- package/apps/studio/test/server-lifecycle.test.ts +11 -1
- package/apps/studio/test/session-runtime-state.test.ts +226 -0
- package/apps/studio/test/session-scope.test.ts +100 -0
- package/apps/studio/test/shell-importmap.test.ts +8 -1
- package/apps/studio/test/sync-autocommit.test.ts +76 -0
- package/apps/studio/test/sync-hardening.test.ts +75 -0
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +36 -0
- package/apps/studio/ws.ts +44 -8
- package/cli/commands/kg.mjs +49 -19
- package/cli/lib/gitignore-block.mjs +3 -0
- package/package.json +11 -9
- package/plugins/design/dependencies.json +1 -1
- package/plugins/design/templates/_shell.html +66 -32
- package/plugins/flow/.claude-plugin/config.schema.json +1 -1
- package/plugins/flow/dependencies.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Whose runtime state is this? — Cloud Phase 27 D3.
|
|
2
|
+
//
|
|
3
|
+
// `_active.json` (which canvas is open, what is selected) and
|
|
4
|
+
// `_canvas-state/<slug>.view.json` (the camera) are PER-MACHINE singletons by
|
|
5
|
+
// design — DDR-115 made them runtime state precisely so they would never be
|
|
6
|
+
// versioned or shared. On a desktop that is exactly right: one machine, one
|
|
7
|
+
// person, one place in the project.
|
|
8
|
+
//
|
|
9
|
+
// A cell breaks the assumption without changing a line of that code. One studio
|
|
10
|
+
// process serves an owner and a viewer at the same time, so both write the same
|
|
11
|
+
// singleton: your colleague opens a different canvas and YOUR tab switches; they
|
|
12
|
+
// pan and your camera moves. Silently, and it reads as flakiness rather than as
|
|
13
|
+
// two people sharing one file.
|
|
14
|
+
//
|
|
15
|
+
// The proxy already knows who each request is — it injects `x-maude-session`, a
|
|
16
|
+
// stable, non-identifying hash of (project, member) minted at sign-in. What was
|
|
17
|
+
// missing is the studio side: a way for code deep in the path helpers to ask
|
|
18
|
+
// "whose request am I on?" without every function between here and there
|
|
19
|
+
// growing a parameter it does not otherwise care about.
|
|
20
|
+
//
|
|
21
|
+
// That is what `AsyncLocalStorage` is for, and this is the shape it fits: one
|
|
22
|
+
// value, established once at the edge (a request handler, a socket message),
|
|
23
|
+
// read at the leaves. The alternative — threading a `session` argument through
|
|
24
|
+
// `getCanvasMeta` → `readCanvasViewRaw` → `canvasViewPath` and a dozen siblings
|
|
25
|
+
// — spreads a cloud concern across every signature in `api.ts`, where it would
|
|
26
|
+
// be forgotten exactly once and reintroduce the bug.
|
|
27
|
+
//
|
|
28
|
+
// OUTSIDE A CELL THIS IS INERT. No header ⇒ no key ⇒ every path resolves byte
|
|
29
|
+
// for byte the way it did before, which is the property the tests assert first.
|
|
30
|
+
|
|
31
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
32
|
+
|
|
33
|
+
/** The header the proxy vouches. Never trusted from a client — the proxy strips
|
|
34
|
+
* every `x-maude-*` before injecting its own (studio-proxy.mjs property 2). */
|
|
35
|
+
export const SESSION_HEADER = 'x-maude-session';
|
|
36
|
+
|
|
37
|
+
const storage = new AsyncLocalStorage<string>();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keep a session key to what it may safely become: a path segment.
|
|
41
|
+
*
|
|
42
|
+
* The value is minted by the proxy as a 16-char hash, so this rejects rather
|
|
43
|
+
* than sanitizes — a key that does not look like one is not a key we should be
|
|
44
|
+
* guessing the intent of, and falling back to the shared singleton is the same
|
|
45
|
+
* behaviour a desktop already has.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeSessionKey(raw: string | null | undefined): string {
|
|
48
|
+
if (!raw) return '';
|
|
49
|
+
return /^[a-zA-Z0-9_-]{1,64}$/.test(raw) ? raw : '';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Read the session key off a request. */
|
|
53
|
+
export function sessionFromRequest(req: { headers: { get(name: string): string | null } }): string {
|
|
54
|
+
try {
|
|
55
|
+
return normalizeSessionKey(req.headers.get(SESSION_HEADER));
|
|
56
|
+
} catch {
|
|
57
|
+
return '';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run `fn` with `key` as the ambient session. An empty key is the shared
|
|
62
|
+
* singleton — i.e. exactly today's behaviour, which is why the desktop path
|
|
63
|
+
* does not need to know this module exists. */
|
|
64
|
+
export function runInSession<T>(key: string, fn: () => T): T {
|
|
65
|
+
const normalized = normalizeSessionKey(key);
|
|
66
|
+
return normalized ? storage.run(normalized, fn) : fn();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Whose request are we on? `''` = nobody in particular (desktop, CLI, tests). */
|
|
70
|
+
export function currentSession(): string {
|
|
71
|
+
return storage.getStore() ?? '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Insert the session segment into a runtime-state path.
|
|
76
|
+
*
|
|
77
|
+
* `_canvas-state/<slug>.view.json` becomes
|
|
78
|
+
* `_canvas-state/<session>/<slug>.view.json`, and nothing at all happens
|
|
79
|
+
* without a session. A DIRECTORY rather than a filename suffix so the whole of
|
|
80
|
+
* one member's runtime state is one subtree — deletable, ignorable, and obvious
|
|
81
|
+
* to a human looking at the disk.
|
|
82
|
+
*/
|
|
83
|
+
export function sessionDir(base: string, key = currentSession()): string {
|
|
84
|
+
return key ? `${base}/${key}` : base;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The same idea for a single FILE — `_active.json` → `_active.<session>.json`.
|
|
89
|
+
*
|
|
90
|
+
* A suffix rather than a subdirectory here because `_active.json` is a named
|
|
91
|
+
* runtime file the gitignore, the runtime-state taxonomy (DDR-115) and
|
|
92
|
+
* `isMaudeRuntimeState` all match by name, and a sibling with the same stem
|
|
93
|
+
* keeps every one of those matchers correct without a fourth list to update.
|
|
94
|
+
*/
|
|
95
|
+
export function sessionFile(absPath: string, key = currentSession()): string {
|
|
96
|
+
if (!key) return absPath;
|
|
97
|
+
const dot = absPath.lastIndexOf('.');
|
|
98
|
+
const slash = Math.max(absPath.lastIndexOf('/'), absPath.lastIndexOf('\\'));
|
|
99
|
+
if (dot <= slash) return `${absPath}.${key}`;
|
|
100
|
+
return `${absPath.slice(0, dot)}.${key}${absPath.slice(dot)}`;
|
|
101
|
+
}
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
|
|
28
28
|
import path from 'node:path';
|
|
29
29
|
|
|
30
|
+
import { withRepoLock } from '../git/repo-lock.ts';
|
|
31
|
+
|
|
30
32
|
/** How long the tree must be quiet before a commit fires. */
|
|
31
33
|
const DEFAULT_DEBOUNCE_MS = 3000;
|
|
32
34
|
|
|
@@ -173,7 +175,11 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
173
175
|
if (timer) clearTimeout(timer);
|
|
174
176
|
timer = setTimeout(() => {
|
|
175
177
|
timer = null;
|
|
176
|
-
void
|
|
178
|
+
// `.catch` and not just `void`: a discarded rejection here is an
|
|
179
|
+
// unhandled-rejection process exit, and this timer fires unattended.
|
|
180
|
+
flush().catch((err) => {
|
|
181
|
+
log.warn?.(`[autocommit] flush failed: ${err instanceof Error ? err.message : err}`);
|
|
182
|
+
});
|
|
177
183
|
}, debounceMs);
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -192,51 +198,34 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
192
198
|
|
|
193
199
|
inFlight = (async (): Promise<CommitOutcome> => {
|
|
194
200
|
try {
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
'--',
|
|
224
|
-
...files,
|
|
225
|
-
],
|
|
226
|
-
{ cwd: repoRoot }
|
|
227
|
-
);
|
|
228
|
-
if (commit.code !== 0) {
|
|
229
|
-
log.warn?.(`[autocommit] git commit failed: ${commit.stderr.trim()}`);
|
|
230
|
-
// The bytes are on disk. Re-queue so the next quiescence retries
|
|
231
|
-
// rather than silently dropping the change from history.
|
|
232
|
-
for (const f of files) touched.add(f);
|
|
233
|
-
return { ok: false, reason: 'git-failed', detail: commit.stderr.trim(), files };
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const head = await run(['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
237
|
-
const sha = head.stdout.trim();
|
|
238
|
-
log.log?.(`[autocommit] ${sha.slice(0, 8)} ${files.length} file(s) by ${who.name}`);
|
|
239
|
-
return { ok: true, sha, files, author: who };
|
|
201
|
+
// Cloud Phase 27 D2 — HOLD THE LOCK ACROSS THE WHOLE SEQUENCE, not per
|
|
202
|
+
// git invocation. `add` and `commit` are two invocations, and the thing
|
|
203
|
+
// that must not land between them is another process's `checkout` or
|
|
204
|
+
// `commit`: that is the half-staged commit the phase's preserved
|
|
205
|
+
// dissent names. `index.lock` cannot express this — it is released the
|
|
206
|
+
// moment `add` returns.
|
|
207
|
+
//
|
|
208
|
+
// The `inFlight` guard above is the in-process half of the same rule
|
|
209
|
+
// and stays: this lock is CROSS-process, and one AutoCommit racing
|
|
210
|
+
// itself would still be a bug.
|
|
211
|
+
return await withRepoLock(repoRoot, 'autocommit', () => commitCycle(files, who));
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// A LOCK TIMEOUT MUST NOT LOSE THE QUEUE, AND MUST NOT KILL THE PROCESS.
|
|
214
|
+
//
|
|
215
|
+
// `withRepoLock` THROWS when it cannot acquire, and `touched` was
|
|
216
|
+
// already cleared above — so without this catch a contended autosave
|
|
217
|
+
// dropped its file list silently (the bytes stay on disk, uncommitted,
|
|
218
|
+
// with nothing to retry them) and rejected a promise the debounce timer
|
|
219
|
+
// discards, which on Node ≥15 takes the process down with it. The
|
|
220
|
+
// trigger is ordinary now, not pathological: post-D2 `pull` and
|
|
221
|
+
// `checkout` hold this lock across a NETWORK operation.
|
|
222
|
+
//
|
|
223
|
+
// Re-queue and report, exactly like a failed `git commit` does. The
|
|
224
|
+
// next quiescence tries again.
|
|
225
|
+
for (const f of files) touched.add(f);
|
|
226
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
227
|
+
log.warn?.(`[autocommit] could not take the repo lock: ${detail}`);
|
|
228
|
+
return { ok: false, reason: 'git-failed', detail, files };
|
|
240
229
|
} finally {
|
|
241
230
|
inFlight = null;
|
|
242
231
|
}
|
|
@@ -245,6 +234,55 @@ export function createAutoCommit(opts: AutoCommitOptions): AutoCommit {
|
|
|
245
234
|
return inFlight;
|
|
246
235
|
}
|
|
247
236
|
|
|
237
|
+
/** The critical section: stage exactly these files, commit them, report. */
|
|
238
|
+
async function commitCycle(files: string[], who: EditAttribution): Promise<CommitOutcome> {
|
|
239
|
+
// Stage ONLY what changed. `git add -A` in a workspace would sweep in
|
|
240
|
+
// whatever else is in the tree — including files a future feature drops
|
|
241
|
+
// there — and the cell must never commit something it wasn't told about.
|
|
242
|
+
const add = await run(['add', '--', ...files], { cwd: repoRoot });
|
|
243
|
+
if (add.code !== 0) {
|
|
244
|
+
log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
|
|
245
|
+
return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
|
|
249
|
+
// Not an error, and committing an empty change would be noise.
|
|
250
|
+
const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
|
|
251
|
+
if (staged.code === 0 && staged.stdout.trim() === '') {
|
|
252
|
+
return { ok: false, reason: 'nothing-to-commit', files };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const commit = await run(
|
|
256
|
+
[
|
|
257
|
+
'-c',
|
|
258
|
+
`user.name=${bot.name}`,
|
|
259
|
+
'-c',
|
|
260
|
+
`user.email=${bot.email}`,
|
|
261
|
+
'commit',
|
|
262
|
+
'--author',
|
|
263
|
+
formatAuthor(who),
|
|
264
|
+
'--only',
|
|
265
|
+
'--message',
|
|
266
|
+
commitMessage(files, who),
|
|
267
|
+
'--',
|
|
268
|
+
...files,
|
|
269
|
+
],
|
|
270
|
+
{ cwd: repoRoot }
|
|
271
|
+
);
|
|
272
|
+
if (commit.code !== 0) {
|
|
273
|
+
log.warn?.(`[autocommit] git commit failed: ${commit.stderr.trim()}`);
|
|
274
|
+
// The bytes are on disk. Re-queue so the next quiescence retries
|
|
275
|
+
// rather than silently dropping the change from history.
|
|
276
|
+
for (const f of files) touched.add(f);
|
|
277
|
+
return { ok: false, reason: 'git-failed', detail: commit.stderr.trim(), files };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const head = await run(['rev-parse', 'HEAD'], { cwd: repoRoot });
|
|
281
|
+
const sha = head.stdout.trim();
|
|
282
|
+
log.log?.(`[autocommit] ${sha.slice(0, 8)} ${files.length} file(s) by ${who.name}`);
|
|
283
|
+
return { ok: true, sha, files, author: who };
|
|
284
|
+
}
|
|
285
|
+
|
|
248
286
|
return {
|
|
249
287
|
note,
|
|
250
288
|
flush,
|
|
@@ -222,6 +222,26 @@ export function createSyncRuntime(
|
|
|
222
222
|
if (!linked) return null;
|
|
223
223
|
const linkedHub = linked;
|
|
224
224
|
|
|
225
|
+
// A CELL'S HISTORY BELONGS TO THE CELL — Cloud Phase 27 D2.
|
|
226
|
+
//
|
|
227
|
+
// `.design/config.json` is the TENANT's file, versioned in their repo, and
|
|
228
|
+
// `linkedHub` is whatever hub their desktop was linked to when they committed
|
|
229
|
+
// it. Honouring that inside a cell would do two things nobody asked for: dial
|
|
230
|
+
// OUT from the cell to a third-party hub carrying the project's canvases, and
|
|
231
|
+
// start a SECOND autocommit over the working tree the hub is already
|
|
232
|
+
// committing — the exact duplication this phase exists to delete.
|
|
233
|
+
//
|
|
234
|
+
// Today this is unreachable by accident: the token lookup below reads
|
|
235
|
+
// `~/.config/maude/hubs.json`, which does not exist in a cell (HOME=/tmp), so
|
|
236
|
+
// it returns null a few lines further down. An accident is not an invariant,
|
|
237
|
+
// and the fix for "it happens to be fine" is to say so out loud.
|
|
238
|
+
if (process.env.MAUDE_WORKSPACE_MODE === '1') {
|
|
239
|
+
console.warn(
|
|
240
|
+
`[sync] ignoring linkedHub ${linkedHub.url} — in a workspace cell the hub owns history and sync. (DDR-209 / Phase 27 D2)`
|
|
241
|
+
);
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
|
|
225
245
|
// DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
|
|
226
246
|
// where a future CI workflow runs `maude design serve` and a PR-controlled
|
|
227
247
|
// linkedHub.url silently grants a remote actor write access in an
|
|
@@ -36,6 +36,21 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
36
36
|
join(designRoot, 'ui', 'Gate.tsx'),
|
|
37
37
|
'export default function G(){return <main/>}\n'
|
|
38
38
|
);
|
|
39
|
+
// DDR-210 — RUNTIME STATE, AT DEPTH.
|
|
40
|
+
//
|
|
41
|
+
// The underscore rule narrowed from "any path segment" to "the first path
|
|
42
|
+
// segment", because a design system's `preview/_components.css` is
|
|
43
|
+
// versioned, shipped, and was 403ing. The narrowing is sound only while
|
|
44
|
+
// DDR-115's taxonomy stays first-segment — which it is today, and which
|
|
45
|
+
// nothing was asserting. `git/service.ts`'s `isMaudeRuntimeState` matches
|
|
46
|
+
// those names at ANY depth, so the two lists now encode different shapes:
|
|
47
|
+
// a future nested runtime dir would be correctly gitignored, correctly
|
|
48
|
+
// hidden from the Changes list, and SERVED TO THE UNTRUSTED CANVAS.
|
|
49
|
+
// These two files are the tripwire.
|
|
50
|
+
mkdirSync(join(designRoot, '_history', 'gate'), { recursive: true });
|
|
51
|
+
writeFileSync(join(designRoot, '_history', 'gate', 'snap.tsx'), 'export default 1\n');
|
|
52
|
+
mkdirSync(join(designRoot, 'system', 'ds', 'preview'), { recursive: true });
|
|
53
|
+
writeFileSync(join(designRoot, 'system', 'ds', 'preview', '_components.css'), '.a{}\n');
|
|
39
54
|
|
|
40
55
|
const port = nextPort();
|
|
41
56
|
const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
|
|
@@ -272,6 +287,13 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
272
287
|
expect(await code('/_canvas-shell.html')).toBe(200);
|
|
273
288
|
// A real .tsx canvas under designRoot transpiles + serves (200).
|
|
274
289
|
expect(await code('/.design/ui/Gate.tsx')).toBe(200);
|
|
290
|
+
// DDR-210 — the two halves of the narrowed underscore rule, pinned.
|
|
291
|
+
// First-segment runtime state stays refused AT DEPTH…
|
|
292
|
+
expect(await code('/.design/_history/gate/snap.tsx')).toBe(403);
|
|
293
|
+
// …and a NESTED underscore that is versioned design-system content is
|
|
294
|
+
// served. This one is the reason the rule was narrowed: it 403'd, so
|
|
295
|
+
// every canvas in every project rendered without its component styles.
|
|
296
|
+
expect(await code('/.design/system/ds/preview/_components.css')).toBe(200);
|
|
275
297
|
// Phase 23 — the capped image-upload route IS reachable from the canvas
|
|
276
298
|
// origin (it must be — drag-drop/paste/picker run inside the iframe). A GET
|
|
277
299
|
// returns 405 (method-not-allowed) which proves the route is REACHED via
|
|
@@ -309,3 +331,42 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
309
331
|
}
|
|
310
332
|
});
|
|
311
333
|
});
|
|
334
|
+
|
|
335
|
+
describe('an SVG served from the canvas origin is not a scripting document', () => {
|
|
336
|
+
test('.svg carries an inert CSP; other static types are untouched', async () => {
|
|
337
|
+
// The canvas origin serves the TENANT's own SVGs. `image/svg+xml` is inert
|
|
338
|
+
// as an <img>, a CSS url() or a <use> target — none of those are browsing
|
|
339
|
+
// contexts — but navigate to one and it is a page, with <script> in it,
|
|
340
|
+
// running on this origin. Only the shell HTML ever got a CSP, so that was a
|
|
341
|
+
// stored-XSS primitive on a `*.<zone>` origin: step one of a three-link
|
|
342
|
+
// cross-tenant chain (an adversarial pass found it; DDR-210 names the other
|
|
343
|
+
// two links). `default-src 'none'; sandbox` costs the legitimate uses
|
|
344
|
+
// nothing, because the policy never applies to them.
|
|
345
|
+
const { root, designRoot } = makeSandbox();
|
|
346
|
+
mkdirSync(join(designRoot, 'system', 'ds', 'assets'), { recursive: true });
|
|
347
|
+
writeFileSync(
|
|
348
|
+
join(designRoot, 'system', 'ds', 'assets', 'mark.svg'),
|
|
349
|
+
'<svg xmlns="http://www.w3.org/2000/svg"><script>1</script></svg>\n'
|
|
350
|
+
);
|
|
351
|
+
writeFileSync(join(designRoot, 'system', 'ds', 'assets', 'tokens.css'), ':root{}\n');
|
|
352
|
+
|
|
353
|
+
const port = nextPort();
|
|
354
|
+
const proc = await bootServer(root, port, { MAUDE_CANVAS_ORIGIN_SPLIT: '1' });
|
|
355
|
+
try {
|
|
356
|
+
const canvas = await readCanvasOrigin(designRoot);
|
|
357
|
+
const head = async (p: string) =>
|
|
358
|
+
(await fetch(canvas + p, { signal: AbortSignal.timeout(2000) })).headers;
|
|
359
|
+
|
|
360
|
+
const svg = await head('/.design/system/ds/assets/mark.svg');
|
|
361
|
+
expect(svg.get('content-type')).toContain('image/svg+xml');
|
|
362
|
+
expect(svg.get('content-security-policy')).toBe("default-src 'none'; sandbox");
|
|
363
|
+
|
|
364
|
+
// …and only SVG. A stylesheet under a CSP that forbids everything is a
|
|
365
|
+
// stylesheet that does not apply.
|
|
366
|
+
const css = await head('/.design/system/ds/assets/tokens.css');
|
|
367
|
+
expect(css.get('content-security-policy')).toBeNull();
|
|
368
|
+
} finally {
|
|
369
|
+
killProc(proc);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// The canvas shell under a based origin — Cloud Phase 27 (DDR-209).
|
|
2
|
+
//
|
|
3
|
+
// The fleet's canvas origin puts the project in the PATH:
|
|
4
|
+
// `canvas.<zone>/<project>/_canvas-shell.html`. Every URL the shell builds must
|
|
5
|
+
// therefore hang off the path it was SERVED from, not off the origin root —
|
|
6
|
+
// otherwise the browser asks for `canvas.<zone>/.design/…`, the data plane reads
|
|
7
|
+
// `.design` as a project name, and every dynamic import fails with
|
|
8
|
+
// "Failed to fetch dynamically imported module".
|
|
9
|
+
//
|
|
10
|
+
// Two mechanisms, because the shell has two kinds of URL and only one of them
|
|
11
|
+
// can be built at runtime:
|
|
12
|
+
//
|
|
13
|
+
// built by JS → `withCap()` prefixes the derived base.
|
|
14
|
+
// the IMPORTMAP → cannot be: it must be static and present before any
|
|
15
|
+
// module loads, and the server cannot rewrite it either
|
|
16
|
+
// (the data plane strips the project segment before the
|
|
17
|
+
// request arrives, so only the browser knows the base).
|
|
18
|
+
// Importmap addresses resolve against the DOCUMENT's base
|
|
19
|
+
// URL, so `./` gets it for free.
|
|
20
|
+
|
|
21
|
+
import { describe, expect, test } from 'bun:test';
|
|
22
|
+
import { readFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const SHELL = readFileSync(
|
|
26
|
+
join(import.meta.dir, '..', '..', '..', 'plugins', 'design', 'templates', '_shell.html'),
|
|
27
|
+
'utf8'
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** The shell's own derivation, mirrored so the test breaks when it changes. */
|
|
31
|
+
const baseOf = (pathname: string) => pathname.replace(/\/_canvas-shell(\.html)?$/, '');
|
|
32
|
+
|
|
33
|
+
describe('the canvas shell hangs off the path it was served from', () => {
|
|
34
|
+
test('the base is derived from location, and is empty at the origin root', () => {
|
|
35
|
+
expect(baseOf('/alligators/_canvas-shell.html')).toBe('/alligators');
|
|
36
|
+
expect(baseOf('/alligators/_canvas-shell')).toBe('/alligators');
|
|
37
|
+
expect(baseOf('/_canvas-shell.html')).toBe('');
|
|
38
|
+
expect(baseOf('/_canvas-shell')).toBe('');
|
|
39
|
+
expect(SHELL).toContain("location.pathname.replace(/\\/_canvas-shell(\\.html)?$/, '')");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('NO importmap entry is origin-absolute', () => {
|
|
43
|
+
// `"/_canvas-runtime/react.js"` resolves against the origin and drops the
|
|
44
|
+
// project. This is the one that cannot be fixed at runtime, so it is the one
|
|
45
|
+
// most worth pinning.
|
|
46
|
+
expect(SHELL).not.toContain('"/_canvas-runtime/');
|
|
47
|
+
expect(SHELL).toContain('"./_canvas-runtime/');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('a relative importmap address resolves under the project, and at the root', () => {
|
|
51
|
+
// The actual browser rule, exercised rather than asserted.
|
|
52
|
+
expect(
|
|
53
|
+
new URL(
|
|
54
|
+
'./_canvas-runtime/react.js',
|
|
55
|
+
'https://canvas.cloud.maude.sh/alligators/_canvas-shell.html?t=x'
|
|
56
|
+
).href
|
|
57
|
+
).toBe('https://canvas.cloud.maude.sh/alligators/_canvas-runtime/react.js');
|
|
58
|
+
expect(
|
|
59
|
+
new URL('./_canvas-runtime/react.js', 'http://localhost:4399/_canvas-shell.html').href
|
|
60
|
+
).toBe('http://localhost:4399/_canvas-runtime/react.js');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('withCap prefixes the base AND carries the capability', () => {
|
|
64
|
+
const base = '/alligators';
|
|
65
|
+
const cap = 'cap-1';
|
|
66
|
+
const withCap = (u: string) => {
|
|
67
|
+
const abs = u.startsWith('/') ? base + u : u;
|
|
68
|
+
return cap ? abs + (abs.includes('?') ? '&' : '?') + 't=' + encodeURIComponent(cap) : abs;
|
|
69
|
+
};
|
|
70
|
+
expect(withCap('/.design/ui/Home.tsx')).toBe('/alligators/.design/ui/Home.tsx?t=cap-1');
|
|
71
|
+
expect(withCap('/_api/canvas-meta?file=x')).toBe('/alligators/_api/canvas-meta?file=x&t=cap-1');
|
|
72
|
+
expect(SHELL).toContain("const abs = u.startsWith('/') ? base + u : u;");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('EVERY absolute URL the shell requests goes through the base', () => {
|
|
76
|
+
// The systematic version of the three separate bugs this file records. Each
|
|
77
|
+
// was one absolute path that nobody had thought about: the module, then the
|
|
78
|
+
// importmap, then `comment-mount.js` — and the last one aborted the mount
|
|
79
|
+
// outright, because it is imported in the same `Promise.all` as the canvas.
|
|
80
|
+
//
|
|
81
|
+
// So this asserts the SHAPE rather than a list: nothing in the shell may
|
|
82
|
+
// form a request from an origin-absolute literal.
|
|
83
|
+
const offenders: string[] = [];
|
|
84
|
+
for (const re of [
|
|
85
|
+
/import\(\s*['"`]\//g, // import('/…')
|
|
86
|
+
/fetch\(\s*['"`]\//g, // fetch('/…')
|
|
87
|
+
/\.href\s*=\s*['"`]\//g, // el.href = '/…'
|
|
88
|
+
/\.src\s*=\s*['"`]\//g, // el.src = '/…'
|
|
89
|
+
/location\.host\s*\+\s*['"`]\//g, // ws://host + '/…'
|
|
90
|
+
]) {
|
|
91
|
+
for (const m of SHELL.matchAll(re)) offenders.push(m[0].trim());
|
|
92
|
+
}
|
|
93
|
+
expect(offenders).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('the URLs that DO appear are the ones withCap builds', () => {
|
|
97
|
+
// Belt to the brace above: the four paths the shell legitimately names must
|
|
98
|
+
// each be reachable only through `withCap`.
|
|
99
|
+
for (const path of ['/_client/comment-mount.js', '/_api/canvas-meta']) {
|
|
100
|
+
const at = SHELL.indexOf(path);
|
|
101
|
+
expect(at).toBeGreaterThan(0);
|
|
102
|
+
// `withCap(` appears within the same expression, before the literal.
|
|
103
|
+
const before = SHELL.slice(Math.max(0, at - 120), at);
|
|
104
|
+
expect(before).toContain('withCap(');
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Cloud Phase 27 D1 — the stub generator, and the reason it is generated.
|
|
2
|
+
//
|
|
3
|
+
// The compiled-artifact assertion lives in `scripts/check-cloud-binary.sh`
|
|
4
|
+
// (two compiles, ~1 min — release-job work). These are the unit-level halves:
|
|
5
|
+
// that a stub covers every runtime export of the module it replaces, and that
|
|
6
|
+
// the value it substitutes cannot crash a boot on a shape nobody remembered.
|
|
7
|
+
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
CLOUD_ELIMINATED,
|
|
14
|
+
CLOUD_FORBIDDEN_STRINGS,
|
|
15
|
+
exportedNames,
|
|
16
|
+
stubSource,
|
|
17
|
+
} from '../cloud-build.ts';
|
|
18
|
+
|
|
19
|
+
const STUDIO = join(import.meta.dir, '..');
|
|
20
|
+
|
|
21
|
+
describe('cloud elimination', () => {
|
|
22
|
+
test('every listed module exists — a typo eliminates nothing, silently', () => {
|
|
23
|
+
for (const rel of CLOUD_ELIMINATED) {
|
|
24
|
+
expect(() => readFileSync(join(STUDIO, rel), 'utf8')).not.toThrow();
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('the stub covers every runtime export of the real module', () => {
|
|
29
|
+
// A missing export is a boot-time crash in the cloud binary and nowhere
|
|
30
|
+
// else — the worst place for it to surface. Generating from the real file
|
|
31
|
+
// is what keeps this true when a module grows a function.
|
|
32
|
+
for (const rel of CLOUD_ELIMINATED) {
|
|
33
|
+
const source = readFileSync(join(STUDIO, rel), 'utf8');
|
|
34
|
+
const stub = stubSource(source);
|
|
35
|
+
for (const name of exportedNames(source)) {
|
|
36
|
+
expect(stub).toContain(`export const ${name} = inert;`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('it reads the forms this codebase actually uses', () => {
|
|
42
|
+
const names = exportedNames(`
|
|
43
|
+
export function alpha() {}
|
|
44
|
+
export async function beta() {}
|
|
45
|
+
export const gamma = 1;
|
|
46
|
+
export let delta = 2;
|
|
47
|
+
export class Epsilon {}
|
|
48
|
+
export { zeta, eta as theta };
|
|
49
|
+
export interface NotThis {}
|
|
50
|
+
export type NorThis = string;
|
|
51
|
+
const notExported = 3;
|
|
52
|
+
`);
|
|
53
|
+
expect(names.sort()).toEqual(['Epsilon', 'alpha', 'beta', 'delta', 'gamma', 'theta', 'zeta']);
|
|
54
|
+
// Types are erased before any of this matters; listing them would emit a
|
|
55
|
+
// `const` where a type was and break the build for no reason.
|
|
56
|
+
expect(names).not.toContain('NotThis');
|
|
57
|
+
expect(names).not.toContain('NorThis');
|
|
58
|
+
expect(names).not.toContain('notExported');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('the inert value survives every shape a caller might use', async () => {
|
|
62
|
+
// The callers are not alike: one calls `createExportJobQueue(...)` and
|
|
63
|
+
// hands the result to a route table, one calls `installLogRing()` for a
|
|
64
|
+
// side effect, one reads a property off what `createAcp` returned. Anything
|
|
65
|
+
// that answers all of them cannot crash a boot.
|
|
66
|
+
const mod = await import(
|
|
67
|
+
`data:text/javascript;base64,${btoa(stubSource('export function makeThing() {}'))}`
|
|
68
|
+
);
|
|
69
|
+
const { makeThing } = mod;
|
|
70
|
+
expect(() => makeThing()).not.toThrow();
|
|
71
|
+
expect(() => makeThing().someMethod()).not.toThrow();
|
|
72
|
+
expect(() => new makeThing()).not.toThrow();
|
|
73
|
+
expect(() => makeThing().a.b.c()).not.toThrow();
|
|
74
|
+
// NOT thenable — an `await` on it must resolve, not hang forever waiting
|
|
75
|
+
// for a `then` that never calls back.
|
|
76
|
+
const awaited = await makeThing();
|
|
77
|
+
expect(awaited).toBeDefined();
|
|
78
|
+
// Destructuring and iteration are both survivable.
|
|
79
|
+
const { anything } = makeThing();
|
|
80
|
+
expect(anything).toBeDefined();
|
|
81
|
+
expect([...makeThing()]).toEqual([]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('a sentinel is a string only our own code can produce', () => {
|
|
85
|
+
// `api.github.com` was the first candidate and is useless: Bun's own
|
|
86
|
+
// runtime carries GITHUB_API_DOMAIN, so it appears in every compiled binary
|
|
87
|
+
// whatever we do, and a gate that cannot go red is one people learn to
|
|
88
|
+
// ignore. Each sentinel must therefore be specific enough to be OURS.
|
|
89
|
+
expect(CLOUD_FORBIDDEN_STRINGS.length).toBeGreaterThan(0);
|
|
90
|
+
for (const s of CLOUD_FORBIDDEN_STRINGS) {
|
|
91
|
+
expect(s).not.toBe('api.github.com');
|
|
92
|
+
expect(s.length).toBeGreaterThan(3);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -61,7 +61,15 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
61
61
|
});
|
|
62
62
|
|
|
63
63
|
test('the cloud sign-in is not offered inside the cloud', () => {
|
|
64
|
-
|
|
64
|
+
// `=== null`, not falsiness. `cloud` is undefined until `/_config` answers,
|
|
65
|
+
// and treating unknown as "not cloud" mounted this bar for one frame in
|
|
66
|
+
// every cloud tab — long enough to fire the one request the cell refuses.
|
|
67
|
+
expect(APP).toContain('{cloud === null ? <CloudBar /> : null}');
|
|
68
|
+
expect(APP).not.toContain('{cloud ? null : <CloudBar />}');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the local export queue does not hydrate before the shell is known', () => {
|
|
72
|
+
expect(APP).toContain('useExportCenter({ enabled: cfg.cloud === null })');
|
|
65
73
|
});
|
|
66
74
|
|
|
67
75
|
test('every component that gates on the shell is actually GIVEN the flag', () => {
|
|
@@ -71,7 +79,9 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
71
79
|
const at = APP.indexOf(call);
|
|
72
80
|
expect(at).toBeGreaterThan(0);
|
|
73
81
|
const props = APP.slice(at, at + 900);
|
|
74
|
-
|
|
82
|
+
// Passed RAW — a `?? null` here would erase the "not known yet" state
|
|
83
|
+
// before any component could see it.
|
|
84
|
+
expect(props).toContain('cloud={cfg.cloud}');
|
|
75
85
|
}
|
|
76
86
|
});
|
|
77
87
|
|
|
@@ -88,3 +98,22 @@ describe('the cloud shell offers nothing it cannot honour', () => {
|
|
|
88
98
|
}
|
|
89
99
|
});
|
|
90
100
|
});
|
|
101
|
+
|
|
102
|
+
describe('"not known yet" survives every layer between /_config and the DOM', () => {
|
|
103
|
+
// The three-line bug: the call sites were fixed to pass `cfg.cloud` raw, and
|
|
104
|
+
// the components still declared `cloud = null` as a default parameter. A
|
|
105
|
+
// default fires on `undefined` — so "we have not asked yet" turned back into
|
|
106
|
+
// "this is not the cloud" one layer further in, the sign-in bar mounted for a
|
|
107
|
+
// frame, and the boot 404 came back looking exactly like the one just fixed.
|
|
108
|
+
const APP_SRC = readFileSync(join(STUDIO, 'client', 'app.jsx'), 'utf8');
|
|
109
|
+
const SETTINGS_SRC = readFileSync(join(STUDIO, 'client', 'panels', 'SettingsPanel.jsx'), 'utf8');
|
|
110
|
+
|
|
111
|
+
test('no component defaults the shell flag', () => {
|
|
112
|
+
for (const [label, src] of [
|
|
113
|
+
['app.jsx', APP_SRC],
|
|
114
|
+
['SettingsPanel.jsx', SETTINGS_SRC],
|
|
115
|
+
] as const) {
|
|
116
|
+
expect(src, label).not.toMatch(/^\s*cloud = (null|false|\{\}),$/m);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|