@1agh/maude 0.53.2 → 0.55.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.
Files changed (55) hide show
  1. package/apps/studio/annotations-layer.tsx +11 -1
  2. package/apps/studio/api.ts +13 -7
  3. package/apps/studio/bin/screenshot.sh +43 -3
  4. package/apps/studio/build.ts +37 -3
  5. package/apps/studio/canvas-build-sandbox.ts +101 -9
  6. package/apps/studio/canvas-build-worker.ts +7 -1
  7. package/apps/studio/canvas-build.ts +9 -1
  8. package/apps/studio/client/app.jsx +226 -25
  9. package/apps/studio/client/export-center.jsx +10 -2
  10. package/apps/studio/client/github.js +7 -0
  11. package/apps/studio/client/panels/CloudBar.jsx +461 -40
  12. package/apps/studio/client/panels/SettingsPanel.jsx +6 -1
  13. package/apps/studio/client/styles/3-shell-maude.css +30 -0
  14. package/apps/studio/client/styles/4-components.css +40 -0
  15. package/apps/studio/cloud/endpoints.ts +48 -6
  16. package/apps/studio/cloud-build.ts +208 -0
  17. package/apps/studio/collab/room.ts +43 -7
  18. package/apps/studio/context.ts +12 -5
  19. package/apps/studio/dist/client.bundle.js +983 -983
  20. package/apps/studio/dist/comment-mount.js +2 -2
  21. package/apps/studio/dist/styles.css +1 -1
  22. package/apps/studio/git/repo-lock.ts +305 -0
  23. package/apps/studio/git/service.ts +171 -21
  24. package/apps/studio/http.ts +159 -13
  25. package/apps/studio/inspect.ts +66 -6
  26. package/apps/studio/server.ts +114 -17
  27. package/apps/studio/session-scope.ts +101 -0
  28. package/apps/studio/sync/autocommit.ts +84 -46
  29. package/apps/studio/sync/index.ts +20 -0
  30. package/apps/studio/test/canvas-origin-gate.test.ts +61 -0
  31. package/apps/studio/test/canvas-shell-base.test.ts +107 -0
  32. package/apps/studio/test/cloud-build.test.ts +95 -0
  33. package/apps/studio/test/cloud-endpoints.test.ts +231 -4
  34. package/apps/studio/test/cloud-shell-surfaces.test.ts +31 -2
  35. package/apps/studio/test/collab-readonly-gate.test.ts +194 -0
  36. package/apps/studio/test/git-system-engine.test.ts +135 -0
  37. package/apps/studio/test/read-only-gate.test.ts +8 -0
  38. package/apps/studio/test/repo-concurrency.test.ts +146 -0
  39. package/apps/studio/test/repo-lock.test.ts +350 -0
  40. package/apps/studio/test/server-lifecycle.test.ts +11 -1
  41. package/apps/studio/test/session-runtime-state.test.ts +226 -0
  42. package/apps/studio/test/session-scope.test.ts +100 -0
  43. package/apps/studio/test/shell-importmap.test.ts +8 -1
  44. package/apps/studio/test/sync-autocommit.test.ts +76 -0
  45. package/apps/studio/test/sync-hardening.test.ts +75 -0
  46. package/apps/studio/use-collab.tsx +21 -1
  47. package/apps/studio/whats-new.json +54 -0
  48. package/apps/studio/ws.ts +44 -8
  49. package/cli/commands/kg.mjs +49 -19
  50. package/cli/lib/gitignore-block.mjs +3 -0
  51. package/package.json +11 -9
  52. package/plugins/design/dependencies.json +1 -1
  53. package/plugins/design/templates/_shell.html +66 -32
  54. package/plugins/flow/.claude-plugin/config.schema.json +1 -1
  55. 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 flush();
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
- // Stage ONLY what changed. `git add -A` in a workspace would sweep in
196
- // whatever else is in the tree — including files a future feature drops
197
- // there — and the cell must never commit something it wasn't told about.
198
- const add = await run(['add', '--', ...files], { cwd: repoRoot });
199
- if (add.code !== 0) {
200
- log.warn?.(`[autocommit] git add failed: ${add.stderr.trim()}`);
201
- return { ok: false, reason: 'git-failed', detail: add.stderr.trim(), files };
202
- }
203
-
204
- // Nothing staged ⇒ the write was a no-op (an echo, or identical bytes).
205
- // Not an error, and committing an empty change would be noise.
206
- const staged = await run(['diff', '--cached', '--name-only'], { cwd: repoRoot });
207
- if (staged.code === 0 && staged.stdout.trim() === '') {
208
- return { ok: false, reason: 'nothing-to-commit', files };
209
- }
210
-
211
- const commit = await run(
212
- [
213
- '-c',
214
- `user.name=${bot.name}`,
215
- '-c',
216
- `user.email=${bot.email}`,
217
- 'commit',
218
- '--author',
219
- formatAuthor(who),
220
- '--only',
221
- '--message',
222
- commitMessage(files, who),
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
+ });