@1agh/maude 0.53.1 → 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 +173 -17
- package/apps/studio/build.ts +37 -3
- package/apps/studio/canvas-build-sandbox.ts +385 -0
- package/apps/studio/canvas-build-worker.ts +85 -0
- package/apps/studio/client/app.jsx +308 -21
- package/apps/studio/client/canvas-url.js +7 -0
- package/apps/studio/client/export-center.jsx +10 -2
- package/apps/studio/client/panels/SettingsPanel.jsx +37 -12
- package/apps/studio/client/report-bug.jsx +125 -30
- package/apps/studio/client/styles/4-components-maude.css +5 -0
- package/apps/studio/client/styles/4-components.css +75 -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 +910 -910
- 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 +383 -31
- package/apps/studio/inspect.ts +66 -6
- package/apps/studio/paths.ts +8 -0
- package/apps/studio/server.ts +216 -53
- 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-build-sandbox.test.ts +69 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +66 -0
- package/apps/studio/test/canvas-shell-base.test.ts +107 -0
- package/apps/studio/test/canvas-url.test.ts +26 -0
- package/apps/studio/test/cloud-build.test.ts +95 -0
- package/apps/studio/test/cloud-session-role.test.ts +131 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +119 -0
- package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
- package/apps/studio/test/config-projection.test.ts +104 -0
- package/apps/studio/test/git-system-engine.test.ts +135 -0
- package/apps/studio/test/read-only-gate.test.ts +58 -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/test/workspace-containment.test.ts +90 -11
- package/apps/studio/use-collab.tsx +21 -1
- package/apps/studio/whats-new.json +47 -2
- package/apps/studio/workspace-mode.ts +161 -28
- package/apps/studio/ws.ts +74 -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
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Cloud Phase 27 — the sandbox child's environment.
|
|
2
|
+
//
|
|
3
|
+
// Diagnosed against a real cell: the canvas module 422'd with "Cannot find
|
|
4
|
+
// native binding" while running the identical worker command by hand with a
|
|
5
|
+
// clean environment worked. The difference was one inherited variable.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { workerEnv } from '../canvas-build-sandbox.ts';
|
|
10
|
+
|
|
11
|
+
describe('workerEnv', () => {
|
|
12
|
+
test('a compiled binary’s VIRTUAL binding path is not handed to a child', () => {
|
|
13
|
+
const env = workerEnv({
|
|
14
|
+
PATH: '/bin',
|
|
15
|
+
NAPI_RS_NATIVE_LIBRARY_PATH: '/$bunfs/root/parser.linux-arm64-gnu.node',
|
|
16
|
+
} as NodeJS.ProcessEnv);
|
|
17
|
+
expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBeUndefined();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('a REAL binding path still is', () => {
|
|
21
|
+
const env = workerEnv({
|
|
22
|
+
PATH: '/bin',
|
|
23
|
+
NAPI_RS_NATIVE_LIBRARY_PATH: '/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu',
|
|
24
|
+
} as NodeJS.ProcessEnv);
|
|
25
|
+
expect(env.NAPI_RS_NATIVE_LIBRARY_PATH).toBe(
|
|
26
|
+
'/maude/node_modules/@oxc-parser/binding-linux-arm64-gnu'
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('the child environment carries no secret — it parses tenant source', () => {
|
|
31
|
+
const env = workerEnv({
|
|
32
|
+
PATH: '/bin',
|
|
33
|
+
HUB_SECRET: 'nope',
|
|
34
|
+
AWS_SECRET_ACCESS_KEY: 'also-nope',
|
|
35
|
+
MAUDE_PROJECT_TOKEN_KEY: 'definitely-nope',
|
|
36
|
+
} as NodeJS.ProcessEnv);
|
|
37
|
+
expect(env.HUB_SECRET).toBeUndefined();
|
|
38
|
+
expect(env.AWS_SECRET_ACCESS_KEY).toBeUndefined();
|
|
39
|
+
expect(env.MAUDE_PROJECT_TOKEN_KEY).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// Cloud Phase 27 B2 — content-addressed media is immutable; everything else
|
|
44
|
+
// revalidates. `no-store` is right on a laptop and is a re-download of every
|
|
45
|
+
// photograph on every pan over the internet.
|
|
46
|
+
import { cacheControlFor } from '../http.ts';
|
|
47
|
+
|
|
48
|
+
describe('cacheControlFor', () => {
|
|
49
|
+
test('a content-addressed name is immutable — the name IS the content', () => {
|
|
50
|
+
for (const p of ['/x/assets/deadbeef.png', '/a/b/0123456789abcdef.mp4']) {
|
|
51
|
+
expect(cacheControlFor(p).cacheControl).toBe('public, max-age=31536000, immutable');
|
|
52
|
+
expect(cacheControlFor(p).addEtag).toBe(false);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('an AUTHORED name revalidates — a designer edits hero.png in place', () => {
|
|
57
|
+
for (const p of ['/x/assets/hero.png', '/x/system/ds/colors_and_type.css', '/x/logos.svg']) {
|
|
58
|
+
expect(cacheControlFor(p).cacheControl).toBe('no-cache');
|
|
59
|
+
expect(cacheControlFor(p).addEtag).toBe(true);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a name that merely LOOKS hex-ish is not treated as content-addressed', () => {
|
|
64
|
+
// `deadbee` is 7 chars; `deadbeefs` has a non-hex char. Getting either
|
|
65
|
+
// wrong caches a mutable file for a year.
|
|
66
|
+
expect(cacheControlFor('/x/deadbee.png').cacheControl).toBe('no-cache');
|
|
67
|
+
expect(cacheControlFor('/x/deadbeefs.png').cacheControl).toBe('no-cache');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -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' });
|
|
@@ -156,6 +171,11 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
156
171
|
'/_api/debug-bundle',
|
|
157
172
|
'/_api/report',
|
|
158
173
|
'/_api/report-fallback',
|
|
174
|
+
// …and the shell capture SPAWNS A PROCESS (screenshot.sh → a headless
|
|
175
|
+
// browser) and renders the whole project. Reachable from the canvas
|
|
176
|
+
// origin it would be a resource-exhaustion lever driven by untrusted
|
|
177
|
+
// canvas code, so it carries the same main-origin-only posture.
|
|
178
|
+
'/_api/shell-shot',
|
|
159
179
|
// Stage F1 — the AssetPicker's asset-listing GET is a shell (main-origin)
|
|
160
180
|
// concern; the untrusted canvas iframe must not enumerate project media.
|
|
161
181
|
'/_api/assets',
|
|
@@ -267,6 +287,13 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
267
287
|
expect(await code('/_canvas-shell.html')).toBe(200);
|
|
268
288
|
// A real .tsx canvas under designRoot transpiles + serves (200).
|
|
269
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);
|
|
270
297
|
// Phase 23 — the capped image-upload route IS reachable from the canvas
|
|
271
298
|
// origin (it must be — drag-drop/paste/picker run inside the iframe). A GET
|
|
272
299
|
// returns 405 (method-not-allowed) which proves the route is REACHED via
|
|
@@ -304,3 +331,42 @@ describe('canvas-origin gate — A1/A2 traversal + privilege containment', () =>
|
|
|
304
331
|
}
|
|
305
332
|
});
|
|
306
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
|
+
});
|
|
@@ -110,3 +110,29 @@ describe('canvasUrl — per-canvas design-system token resolution', () => {
|
|
|
110
110
|
).toBeNull();
|
|
111
111
|
});
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
// Cloud Phase 27 (DDR-209) — the canvas origin's capability.
|
|
115
|
+
//
|
|
116
|
+
// In the cloud the canvas origin is a cookieless, cross-site hostname: a cookie
|
|
117
|
+
// scoped widely enough to cover it would be readable by the untrusted canvas
|
|
118
|
+
// content itself, which is the one thing the DDR-054 split exists to prevent.
|
|
119
|
+
// So the capability rides in the URL. It reaches `cfg` through the `/_config`
|
|
120
|
+
// projection — which silently dropped it once, and would have 401'd every
|
|
121
|
+
// canvas iframe in production (see `config-projection.test.ts`).
|
|
122
|
+
|
|
123
|
+
test('a canvas URL carries the capability when the server minted one', () => {
|
|
124
|
+
const url = canvasUrl('.design/ui/Home.tsx', {
|
|
125
|
+
designRel: '.design',
|
|
126
|
+
canvasOrigin: 'https://canvas.cloud.maude.sh/alligators',
|
|
127
|
+
canvasToken: 'cap-abc123',
|
|
128
|
+
});
|
|
129
|
+
expect(url.startsWith('https://canvas.cloud.maude.sh/alligators/_canvas-shell.html?')).toBe(true);
|
|
130
|
+
expect(new URL(url).searchParams.get('t')).toBe('cap-abc123');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('a desktop URL is byte-identical to before — no capability, no query', () => {
|
|
134
|
+
// The loopback canvas origin needs none, so this must not change for the
|
|
135
|
+
// shell that has always worked.
|
|
136
|
+
const url = canvasUrl('.design/ui/Home.tsx', { designRel: '.design' });
|
|
137
|
+
expect(new URL(url, 'http://x').searchParams.has('t')).toBe(false);
|
|
138
|
+
});
|