@1agh/maude 0.60.6 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/apps/studio/acp/index.ts +1 -0
  2. package/apps/studio/ai-banner.tsx +1 -0
  3. package/apps/studio/annotations-context-toolbar.tsx +3 -1
  4. package/apps/studio/annotations-layer.tsx +33 -16
  5. package/apps/studio/api.ts +108 -18
  6. package/apps/studio/artboard-guides-overlay.tsx +5 -1
  7. package/apps/studio/assets-s3.ts +6 -1
  8. package/apps/studio/bin/_import-figma.mjs +8 -3
  9. package/apps/studio/build.ts +1 -1
  10. package/apps/studio/canvas-artifacts.ts +21 -0
  11. package/apps/studio/canvas-build.ts +14 -9
  12. package/apps/studio/canvas-comment-mount.tsx +27 -30
  13. package/apps/studio/canvas-icons.tsx +1 -1
  14. package/apps/studio/canvas-lib.tsx +94 -5
  15. package/apps/studio/canvas-list-watch.ts +14 -1
  16. package/apps/studio/canvas-shell.tsx +8 -0
  17. package/apps/studio/client/app.jsx +224 -45
  18. package/apps/studio/client/panels/GitPanel.jsx +121 -13
  19. package/apps/studio/client/panels/SettingsPanel.jsx +1 -1
  20. package/apps/studio/client/panels/SyncConsentDialog.jsx +182 -0
  21. package/apps/studio/client/panels/SyncPanel.jsx +595 -1
  22. package/apps/studio/client/styles/3-shell-maude.css +38 -0
  23. package/apps/studio/clip-ops.ts +8 -1
  24. package/apps/studio/cloud/endpoints.ts +265 -3
  25. package/apps/studio/collab/origins.ts +3 -1
  26. package/apps/studio/comments-overlay.tsx +5 -0
  27. package/apps/studio/config.schema.json +24 -0
  28. package/apps/studio/context-menu.tsx +40 -27
  29. package/apps/studio/context.ts +57 -8
  30. package/apps/studio/cursors-overlay.tsx +25 -13
  31. package/apps/studio/dist/client.bundle.js +686 -686
  32. package/apps/studio/dist/comment-mount.js +2 -2
  33. package/apps/studio/dist/styles.css +1 -1
  34. package/apps/studio/exporters/jobs.ts +77 -15
  35. package/apps/studio/exporters/remote.ts +190 -0
  36. package/apps/studio/exporters/video-encode-lib.ts +10 -4
  37. package/apps/studio/figma/to-strokes.ts +11 -9
  38. package/apps/studio/gifenc.d.ts +51 -0
  39. package/apps/studio/git/log-format.ts +88 -0
  40. package/apps/studio/git/safe-rel.ts +96 -0
  41. package/apps/studio/git/service.ts +46 -27
  42. package/apps/studio/hmr-broadcast.ts +10 -0
  43. package/apps/studio/http.ts +384 -6
  44. package/apps/studio/participants-chrome.tsx +1 -0
  45. package/apps/studio/photo-store.ts +7 -0
  46. package/apps/studio/react-augment.d.ts +17 -0
  47. package/apps/studio/runtime-bundle.ts +6 -1
  48. package/apps/studio/server.ts +43 -8
  49. package/apps/studio/sync/agent.ts +70 -82
  50. package/apps/studio/sync/asset-push.ts +28 -6
  51. package/apps/studio/sync/autocommit.ts +106 -5
  52. package/apps/studio/sync/cell-file-events.ts +117 -0
  53. package/apps/studio/sync/cell-pairing.ts +20 -5
  54. package/apps/studio/sync/cell-write-nudge.ts +244 -0
  55. package/apps/studio/sync/codec.ts +155 -3
  56. package/apps/studio/sync/cold-start-apply.ts +211 -0
  57. package/apps/studio/sync/ctl-heal.ts +253 -0
  58. package/apps/studio/sync/ctl-provider.ts +217 -0
  59. package/apps/studio/sync/decide-file.ts +335 -0
  60. package/apps/studio/sync/file-ledger.ts +581 -0
  61. package/apps/studio/sync/file-membership.ts +32 -0
  62. package/apps/studio/sync/file-plane.ts +1400 -0
  63. package/apps/studio/sync/file-pull.ts +41 -4
  64. package/apps/studio/sync/hub-link.ts +16 -1
  65. package/apps/studio/sync/hub-listing.ts +46 -0
  66. package/apps/studio/sync/hubs-config.ts +16 -0
  67. package/apps/studio/sync/index.ts +919 -214
  68. package/apps/studio/sync/journal-client.ts +200 -0
  69. package/apps/studio/sync/migrate-seed.ts +99 -67
  70. package/apps/studio/sync/poke.ts +50 -0
  71. package/apps/studio/sync/projection.ts +13 -0
  72. package/apps/studio/sync/pull-budget.ts +86 -0
  73. package/apps/studio/sync/settings.ts +110 -0
  74. package/apps/studio/sync/status.ts +68 -0
  75. package/apps/studio/sync/trash.ts +243 -0
  76. package/apps/studio/sync/untrusted.ts +30 -10
  77. package/apps/studio/test/_helpers.ts +8 -0
  78. package/apps/studio/test/canvas-build.test.ts +63 -0
  79. package/apps/studio/test/canvas-list-watch.test.ts +17 -0
  80. package/apps/studio/test/canvas-move-api.test.ts +31 -0
  81. package/apps/studio/test/canvas-origin-gate.test.ts +12 -0
  82. package/apps/studio/test/canvas-shell-build-error.test.ts +49 -0
  83. package/apps/studio/test/cloud-history-hardening.test.ts +165 -0
  84. package/apps/studio/test/cloud-history-posture.test.ts +230 -0
  85. package/apps/studio/test/cloud-session-role.test.ts +30 -0
  86. package/apps/studio/test/cloud-shell-surfaces.test.ts +39 -0
  87. package/apps/studio/test/cold-start-apply.test.ts +303 -0
  88. package/apps/studio/test/collab-stress.test.ts +9 -1
  89. package/apps/studio/test/export-lane.test.ts +245 -0
  90. package/apps/studio/test/fixtures/video-comp-fixture.tsx +1 -1
  91. package/apps/studio/test/git-log-format.test.ts +95 -0
  92. package/apps/studio/test/git-safe-rel.test.ts +132 -0
  93. package/apps/studio/test/hmr-broadcast.test.ts +26 -0
  94. package/apps/studio/test/peer-selection-follows-camera.test.tsx +131 -0
  95. package/apps/studio/test/shared-doc-cell-pairing.test.ts +5 -2
  96. package/apps/studio/test/sync-agent.test.ts +78 -0
  97. package/apps/studio/test/sync-asset-push.test.ts +90 -16
  98. package/apps/studio/test/sync-autocommit.test.ts +80 -0
  99. package/apps/studio/test/sync-cell-write-nudge.test.ts +346 -0
  100. package/apps/studio/test/sync-ctl-channel.test.ts +508 -0
  101. package/apps/studio/test/sync-decide-file.test.ts +420 -0
  102. package/apps/studio/test/sync-file-ledger.test.ts +334 -0
  103. package/apps/studio/test/sync-file-membership.test.ts +17 -1
  104. package/apps/studio/test/sync-file-plane.test.ts +976 -0
  105. package/apps/studio/test/sync-hub-listing.test.ts +46 -0
  106. package/apps/studio/test/sync-meta-codec.test.ts +76 -0
  107. package/apps/studio/test/sync-move-retirement.test.ts +231 -0
  108. package/apps/studio/test/sync-panel-surface.test.ts +20 -0
  109. package/apps/studio/test/sync-path-pull.test.ts +67 -1
  110. package/apps/studio/test/sync-pull-budget.test.ts +169 -0
  111. package/apps/studio/test/sync-seed-defers-to-hub.test.ts +83 -0
  112. package/apps/studio/test/sync-settings-routes.test.ts +195 -0
  113. package/apps/studio/test/sync-settings.test.ts +151 -0
  114. package/apps/studio/test/sync-status.test.ts +69 -0
  115. package/apps/studio/test/sync-trash.test.ts +132 -0
  116. package/apps/studio/test/workspace-containment.test.ts +45 -9
  117. package/apps/studio/tsconfig.json +9 -10
  118. package/apps/studio/use-annotation-resize.tsx +14 -3
  119. package/apps/studio/use-collab.tsx +3 -1
  120. package/apps/studio/whats-new.json +99 -0
  121. package/apps/studio/workspace-mode.ts +110 -62
  122. package/apps/studio/ws.ts +22 -1
  123. package/cli/bin/claude-design-server.mjs +19 -0
  124. package/cli/commands/design.mjs +25 -6
  125. package/cli/commands/hub-workspace.mjs +243 -22
  126. package/cli/commands/hub-workspace.test.mjs +171 -0
  127. package/cli/commands/hub.mjs +71 -1
  128. package/cli/lib/design-link.mjs +186 -1
  129. package/cli/lib/design-ownership.mjs +330 -0
  130. package/cli/lib/design-ownership.test.mjs +329 -0
  131. package/cli/lib/hubs-config.mjs +21 -0
  132. package/cli/lib/hubs-config.test.mjs +47 -1
  133. package/cli/lib/workspace-plan.mjs +298 -5
  134. package/cli/lib/workspace-plan.test.mjs +215 -1
  135. package/package.json +10 -10
  136. package/plugins/design/templates/_shell.html +43 -2
  137. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -1
  138. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +1 -1
  139. package/apps/studio/server.mjs +0 -1312
  140. package/apps/studio/sync/asset-pull.ts +0 -210
  141. package/apps/studio/sync/asset-push-worker.ts +0 -84
  142. package/apps/studio/sync/asset-sweep.ts +0 -262
  143. package/apps/studio/test/sync-asset-pull.test.ts +0 -161
  144. package/apps/studio/test/sync-asset-push-worker.test.ts +0 -183
  145. package/apps/studio/test/sync-asset-sweep.test.ts +0 -243
@@ -21,6 +21,23 @@ const DOMAIN_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
21
21
  const BUCKET_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
22
22
  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23
23
 
24
+ /**
25
+ * Normalize a backup namespace into a key-safe segment — Phase 0 F3.
26
+ *
27
+ * Deliberately narrow: this becomes an object-key prefix, so anything that
28
+ * could re-enter the keyspace elsewhere (`/`, `..`, whitespace) has to be gone
29
+ * rather than escaped. Mirrors the charset the cell validates its tenant id
30
+ * with, for the same reason.
31
+ */
32
+ export function sanitizeBackupPrefix(raw) {
33
+ return String(raw ?? '')
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/[^a-z0-9._-]+/g, '-')
37
+ .replace(/^[-.]+|[-.]+$/g, '')
38
+ .slice(0, 64);
39
+ }
40
+
24
41
  /**
25
42
  * @typedef {object} WorkspaceConfig
26
43
  * @property {string} domain public hostname, e.g. design.acme.com
@@ -38,6 +55,36 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
38
55
  * problems, not the first one: someone filling this in wants to fix everything
39
56
  * in one pass, not to play whack-a-mole with a wizard.
40
57
  */
58
+ /**
59
+ * A seed URL with its credential masked, for anything an eye or a log can see.
60
+ *
61
+ * The recommended seed URL carries a live GitHub PAT in userinfo
62
+ * (`https://x-access-token:<PAT>@github.com/org/repo.git` — `seed-repo.mjs`
63
+ * accepts nothing else). The AWS spike printed it raw, and the token landed in
64
+ * SSM command history, CloudTrail and a session transcript before anyone
65
+ * noticed; it had to be revoked. `.env` is written `0600` — stdout has no such
66
+ * thing, so nothing may print the configured value directly.
67
+ *
68
+ * The host and path survive, because those are what an operator reads the line
69
+ * to confirm.
70
+ */
71
+ export function safeSeedUrl(raw) {
72
+ const value = String(raw ?? '').trim();
73
+ if (!value) return null;
74
+ try {
75
+ const u = new URL(value);
76
+ if (u.username || u.password) {
77
+ u.username = '***';
78
+ u.password = '';
79
+ }
80
+ return u.toString();
81
+ } catch {
82
+ // `git@host:org/repo.git` is not a URL but carries no secret; anything
83
+ // else unparseable is not worth guessing at with a token possibly inside.
84
+ return /^git@[^\s:]+:[^\s]+$/.test(value) ? value : '<unparseable seed url>';
85
+ }
86
+ }
87
+
41
88
  export function validateWorkspaceConfig(raw = {}) {
42
89
  const errors = [];
43
90
  const cfg = { ...raw };
@@ -47,6 +94,12 @@ export function validateWorkspaceConfig(raw = {}) {
47
94
  // suite ends up needing a purchased domain before it can run even once.
48
95
  cfg.local = raw.local === true;
49
96
 
97
+ // feature-cloud-export-render-workers (DDR-230) — the optional maude-render
98
+ // sidecar. Opt-in: it is the one container in the stack that carries a
99
+ // browser, and an operator must mean that. Without it the workspace still
100
+ // works; browser-format exports refuse with a remedy (lane `none`).
101
+ cfg.render = raw.render === true;
102
+
50
103
  cfg.domain = String(raw.domain ?? '')
51
104
  .trim()
52
105
  .toLowerCase()
@@ -87,6 +140,43 @@ export function validateWorkspaceConfig(raw = {}) {
87
140
  );
88
141
  }
89
142
 
143
+ // Spike finding M7 — the SECOND hostname, for the canvas origin. The studio
144
+ // splits the canvas iframe onto its own origin (DDR-054), and without a
145
+ // public name for it the iframe falls back to `http://localhost:<container
146
+ // port>` — an address only the server itself can reach. The stack then comes
147
+ // up, all eight verification steps pass, and every canvas renders as a blank
148
+ // frame with ERR_CONNECTION_REFUSED in the console. The hub already routes
149
+ // by Host (`isCanvasHost`), so all this needs is a name: DNS → Caddy block →
150
+ // MAUDE_PUBLIC_CANVAS_ORIGIN, all rendered from this one value.
151
+ cfg.canvasDomain = String(raw.canvasDomain ?? '')
152
+ .trim()
153
+ .toLowerCase()
154
+ .replace(/^https?:\/\//, '')
155
+ .replace(/\/.*$/, '');
156
+ if (cfg.canvasDomain) {
157
+ const canvasIsLoopback =
158
+ cfg.canvasDomain === 'localhost' || cfg.canvasDomain.endsWith('.localhost');
159
+ if (cfg.local && canvasIsLoopback) {
160
+ // same carve-out as the main domain — `canvas.localhost` is loopback by RFC 6761
161
+ } else if (!DOMAIN_RE.test(cfg.canvasDomain)) {
162
+ errors.push(`canvasDomain "${cfg.canvasDomain}" is not a valid hostname`);
163
+ } else if (!cfg.canvasDomain.includes('.')) {
164
+ errors.push('canvasDomain must be fully qualified');
165
+ }
166
+ if (cfg.local && !canvasIsLoopback) {
167
+ errors.push(
168
+ `--local requires a loopback canvas name (*.localhost); "${cfg.canvasDomain}" is not one`
169
+ );
170
+ }
171
+ // The SAME name would collapse the origin split this domain exists to
172
+ // create — one origin again, cookies reachable from tenant code (DDR-054).
173
+ if (cfg.canvasDomain === cfg.domain) {
174
+ errors.push('canvasDomain must differ from domain — a same-origin canvas defeats the split');
175
+ }
176
+ } else {
177
+ cfg.canvasDomain = null;
178
+ }
179
+
90
180
  cfg.acmeEmail = String(raw.acmeEmail ?? '').trim();
91
181
  if (!cfg.acmeEmail) {
92
182
  if (!cfg.local) errors.push('acmeEmail is required (Let’s Encrypt expiry notices)');
@@ -136,6 +226,60 @@ export function validateWorkspaceConfig(raw = {}) {
136
226
  };
137
227
  }
138
228
 
229
+ // BYO identity — Track C C6. Threaded through validation, .env AND compose;
230
+ // a var written into one but not the other never reaches the container, which
231
+ // already shipped once with MAUDE_ADMIN_PASSWORD (see renderCompose).
232
+ if (raw.oidc) {
233
+ const o = { ...raw.oidc };
234
+ o.issuer = String(o.issuer ?? '')
235
+ .trim()
236
+ .replace(/\/+$/, '');
237
+ o.mode = o.mode === 'strict' ? 'strict' : 'hybrid';
238
+ o.domains = String(o.domains ?? '').trim();
239
+ if (!o.issuer) errors.push('oidc.issuer is required when oidc is configured');
240
+ else if (!/^https:\/\//.test(o.issuer)) errors.push('oidc.issuer must be https');
241
+ if (!o.clientId) errors.push('oidc.clientId is required when oidc is configured');
242
+ if (!o.clientSecret) errors.push('oidc.clientSecret is required when oidc is configured');
243
+ // A filter, never a grant — but required, because without it every subject
244
+ // at a public issuer can queue itself into the operator's pending list.
245
+ if (!o.domains) errors.push('oidc.domains is required when oidc is configured');
246
+ cfg.oidc = o;
247
+ }
248
+
249
+ // The backup namespace — Phase 0 F3.
250
+ //
251
+ // Until now nothing here mentioned MAUDE_BACKUP_PREFIX at all: the CELL
252
+ // entrypoint sets it (derived from the tenant id) and a self-hosted
253
+ // workspace therefore backed up to the bucket ROOT by construction. Two hubs
254
+ // on one bucket then shared a keyspace, which is how their generations
255
+ // interleaved and pruned across each other.
256
+ //
257
+ // NEW RENDERS ONLY — and that restriction is the point. Adding a prefix to a
258
+ // deployment that already has generations at the root moves it to a DISJOINT
259
+ // keyspace (`prefixedTarget` rewrites `list('backups/')` to
260
+ // `list('<prefix>/backups/')`), so every existing generation goes invisible
261
+ // in one config change: orphaned, unprunable, and — after the next volume
262
+ // loss — a cold start that sees zero generations and seeds instead. The fix
263
+ // would re-open the exact destruction it exists to close.
264
+ //
265
+ // Safe to leave off, because the WRITE-side identity refusal already stops
266
+ // the destruction at the bare root without any prefix. Here the prefix is a
267
+ // remedy, not the safety mechanism.
268
+ //
269
+ // `backupPrefix: null` is the caller saying "existing deployment, leave it
270
+ // alone"; a string is an explicit choice; undefined derives one.
271
+ if (raw.backupPrefix === null) {
272
+ cfg.backupPrefix = null;
273
+ } else if (raw.backupPrefix !== undefined && String(raw.backupPrefix).trim() !== '') {
274
+ const explicit = sanitizeBackupPrefix(raw.backupPrefix);
275
+ if (!explicit) errors.push(`backupPrefix "${raw.backupPrefix}" has no usable characters`);
276
+ cfg.backupPrefix = explicit || null;
277
+ } else {
278
+ // Derived from the address, which is the one identifier the operator has
279
+ // already had to make unique — DNS enforced it.
280
+ cfg.backupPrefix = sanitizeBackupPrefix(cfg.domain) || null;
281
+ }
282
+
139
283
  if (raw.seedRepo !== undefined && raw.seedRepo !== null && String(raw.seedRepo).trim() !== '') {
140
284
  const seed = String(raw.seedRepo).trim();
141
285
  if (!/^(https?:\/\/|git@|ssh:\/\/)/.test(seed)) {
@@ -172,7 +316,7 @@ export function workspaceBaseUrl(cfg) {
172
316
  * without it every client shares one rate-limit bucket and a single attacker's
173
317
  * login flood limits everybody (DDR-194 §4).
174
318
  */
175
- export function envEntries(cfg, { hubSecret, adminPassword }) {
319
+ export function envEntries(cfg, { hubSecret, adminPassword, renderSecret }) {
176
320
  const entries = [
177
321
  {
178
322
  key: 'PUBLIC_DOMAIN',
@@ -234,10 +378,67 @@ export function envEntries(cfg, { hubSecret, adminPassword }) {
234
378
  { key: 'MAUDE_S3_SECRET_ACCESS_KEY', value: cfg.s3.secretAccessKey },
235
379
  { key: 'MAUDE_S3_REGION', value: cfg.s3.region }
236
380
  );
381
+ if (cfg.backupPrefix) {
382
+ entries.push({
383
+ key: 'MAUDE_BACKUP_PREFIX',
384
+ value: cfg.backupPrefix,
385
+ comment: 'this hub owns this keyspace; never point a second hub at it',
386
+ });
387
+ }
388
+ }
389
+ if (cfg.oidc) {
390
+ entries.push(
391
+ {
392
+ key: 'HUB_OIDC_MODE',
393
+ value: cfg.oidc.mode,
394
+ comment: 'hybrid = password login still works; strict = OIDC only',
395
+ },
396
+ { key: 'HUB_OIDC_ISSUER', value: cfg.oidc.issuer },
397
+ { key: 'HUB_OIDC_CLIENT_ID', value: cfg.oidc.clientId },
398
+ { key: 'HUB_OIDC_CLIENT_SECRET', value: cfg.oidc.clientSecret },
399
+ {
400
+ key: 'HUB_OIDC_ALLOWED_DOMAINS',
401
+ value: cfg.oidc.domains,
402
+ comment: 'a filter, never a grant — a permitted domain still needs an account',
403
+ }
404
+ );
237
405
  }
238
406
  if (cfg.seedRepo) {
239
407
  entries.push({ key: 'MAUDE_SEED_REPO', value: cfg.seedRepo, comment: 'cloned on first boot' });
240
408
  }
409
+ if (cfg.canvasDomain) {
410
+ entries.push(
411
+ {
412
+ key: 'CANVAS_DOMAIN',
413
+ value: cfg.canvasDomain,
414
+ comment: 'second hostname for the canvas origin; Caddy serves it, DNS must point here too',
415
+ },
416
+ {
417
+ key: 'MAUDE_PUBLIC_CANVAS_ORIGIN',
418
+ value: `${cfg.local ? 'http' : 'https'}://${cfg.canvasDomain}`,
419
+ comment: 'without this the canvas iframe points at a container-internal port (M7)',
420
+ }
421
+ );
422
+ }
423
+ if (cfg.render) {
424
+ entries.push(
425
+ {
426
+ key: 'MAUDE_RENDER_SECRET',
427
+ value: renderSecret,
428
+ comment: 'hub ↔ render ingress bearer (DDR-230) — its own secret, never HUB_SECRET',
429
+ },
430
+ {
431
+ key: 'MAUDE_RENDER_URL',
432
+ value: 'http://render:8790',
433
+ comment: 'compose-internal address the hub dispatches export jobs to',
434
+ },
435
+ {
436
+ key: 'MAUDE_RENDER_CANVAS_BASE',
437
+ value: 'http://hub:1234',
438
+ comment: 'where the render service fetches canvases — the hub, over the compose network',
439
+ }
440
+ );
441
+ }
241
442
  entries.push({
242
443
  key: 'MAUDE_IMAGE_TAG',
243
444
  value: cfg.imageTag,
@@ -255,12 +456,36 @@ export function renderEnv(entries) {
255
456
  ];
256
457
  for (const e of entries) {
257
458
  if (e.comment) lines.push(`# ${e.comment}`);
258
- lines.push(`${e.key}=${e.value}`);
459
+ lines.push(`${e.key}=${renderEnvValue(e.value)}`);
259
460
  lines.push('');
260
461
  }
261
462
  return `${lines.join('\n').trimEnd()}\n`;
262
463
  }
263
464
 
465
+ /**
466
+ * Render one `.env` value safely (F8).
467
+ *
468
+ * Two failure modes a raw `${value}` opens, both with operator-pasted material
469
+ * (an S3 secret, an OIDC client secret):
470
+ * - a NEWLINE injects an arbitrary extra line, which `readExistingEnv`'s
471
+ * `^KEY=…$` parser then accepts and PERSISTS across the "re-run is the
472
+ * upgrade path" flow — `secret\nHUB_INSECURE_HTTP=1` becomes real config.
473
+ * - a `$` is re-interpolated by `docker compose`, so the container silently
474
+ * gets a different value than the file shows — an unexplained lockout.
475
+ * A control character is rejected outright (it cannot be meant); everything
476
+ * else is single-quoted, and an embedded single quote is escaped the POSIX way.
477
+ */
478
+ function renderEnvValue(raw) {
479
+ const v = String(raw ?? '');
480
+ if (/[\n\r\0]/.test(v)) {
481
+ throw new Error('refusing to write a .env value containing a newline or control character');
482
+ }
483
+ if (v === '') return '';
484
+ // Single-quote: inside single quotes the shell and compose interpolate
485
+ // nothing. `'\''` is the POSIX way to embed a single quote.
486
+ return `'${v.replace(/'/g, `'\\''`)}'`;
487
+ }
488
+
264
489
  /**
265
490
  * The compose stack: hub + Caddy, MinIO only under the `dev` profile.
266
491
  *
@@ -288,9 +513,27 @@ export function renderCompose(cfg) {
288
513
  'MAUDE_S3_ACCESS_KEY_ID',
289
514
  'MAUDE_S3_SECRET_ACCESS_KEY',
290
515
  'MAUDE_S3_REGION',
516
+ // Written into .env AND forwarded here. This list is hand-maintained,
517
+ // and a var present in one but not the other never reaches the
518
+ // container — which already shipped once, with MAUDE_ADMIN_PASSWORD.
519
+ ...(cfg.backupPrefix ? ['MAUDE_BACKUP_PREFIX'] : []),
520
+ ]
521
+ : []),
522
+ ...(cfg.oidc
523
+ ? [
524
+ 'HUB_OIDC_MODE',
525
+ 'HUB_OIDC_ISSUER',
526
+ 'HUB_OIDC_CLIENT_ID',
527
+ 'HUB_OIDC_CLIENT_SECRET',
528
+ 'HUB_OIDC_ALLOWED_DOMAINS',
291
529
  ]
292
530
  : []),
293
531
  ...(cfg.seedRepo ? ['MAUDE_SEED_REPO'] : []),
532
+ // Written into .env AND forwarded here — same hand-maintained pair as the
533
+ // S3 block above, same failure mode when they drift (M7: the origin was
534
+ // supported end to end and no deployment path ever set it).
535
+ ...(cfg.canvasDomain ? ['MAUDE_PUBLIC_CANVAS_ORIGIN'] : []),
536
+ ...(cfg.render ? ['MAUDE_RENDER_SECRET', 'MAUDE_RENDER_URL', 'MAUDE_RENDER_CANVAS_BASE'] : []),
294
537
  ];
295
538
 
296
539
  return `# Maude workspace — generated by \`maude hub workspace-up\`.
@@ -324,14 +567,33 @@ ${envLines(hubEnv)}
324
567
  expose:
325
568
  - "1234"
326
569
 
327
- caddy:
570
+ ${
571
+ cfg.render
572
+ ? ` # The maude-render sidecar (DDR-230) — the ONE container here that holds a
573
+ # browser. It renders export jobs the hub dispatches; it holds no hub
574
+ # secret, no volume, no tenant store, and it refuses to boot if a known
575
+ # secret variable reaches it. Not exposed publicly — the hub talks to it
576
+ # over the compose network only.
577
+ render:
578
+ image: ghcr.io/1agh/maude-render:\${MAUDE_IMAGE_TAG:-latest}
579
+ restart: unless-stopped
580
+ environment:
581
+ MAUDE_RENDER_SECRET: \${MAUDE_RENDER_SECRET}
582
+ # This service only fetches canvases from the hub next door.
583
+ MAUDE_RENDER_CANVAS_ORIGINS: http://hub:1234
584
+ expose:
585
+ - "8790"
586
+
587
+ `
588
+ : ''
589
+ } caddy:
328
590
  image: caddy:2-alpine
329
591
  restart: unless-stopped
330
592
  ports:
331
593
  - "80:80"${cfg.local ? '' : '\n - "443:443"'}
332
594
  environment:
333
595
  PUBLIC_DOMAIN: \${PUBLIC_DOMAIN}
334
- ACME_EMAIL: \${ACME_EMAIL}
596
+ ACME_EMAIL: \${ACME_EMAIL}${cfg.canvasDomain ? '\n CANVAS_DOMAIN: ${CANVAS_DOMAIN}' : ''}
335
597
  volumes:
336
598
  - ./Caddyfile:/etc/caddy/Caddyfile:ro
337
599
  - caddy-data:/data
@@ -412,6 +674,24 @@ export function renderCaddyfile(cfg) {
412
674
  }
413
675
  `;
414
676
 
677
+ const canvasSite = cfg.canvasDomain
678
+ ? `
679
+ # The CANVAS origin (M7) — a second hostname for the same hub. The hub routes
680
+ # by Host (isCanvasHost) and proxies to the studio's canvas listener itself, so
681
+ # this block needs no port knowledge; it exists so the name resolves and gets a
682
+ # certificate. Deliberately a separate ORIGIN, not a path: the split is what
683
+ # keeps studio cookies unreachable from tenant canvas code (DDR-054).
684
+ ${cfg.local ? 'http://{$CANVAS_DOMAIN}' : '{$CANVAS_DOMAIN}'} {
685
+ encode zstd gzip
686
+ reverse_proxy hub:1234 {
687
+ header_up X-Forwarded-For {remote_host}
688
+ header_up X-Forwarded-Proto {scheme}
689
+ header_up Host {host}
690
+ }
691
+ }
692
+ `
693
+ : '';
694
+
415
695
  return `# Maude workspace — generated by \`maude hub workspace-up\`.
416
696
  ${header}
417
697
  ${site} {
@@ -427,7 +707,7 @@ ${site} {
427
707
  header_up Host {host}
428
708
  }
429
709
  }
430
- `;
710
+ ${canvasSite}`;
431
711
  }
432
712
 
433
713
  /**
@@ -481,6 +761,13 @@ export function verificationPlan(cfg) {
481
761
  }
482
762
  );
483
763
  }
764
+ if (cfg.render) {
765
+ steps.push({
766
+ id: 'render-health',
767
+ title: 'the render service answers and is configured',
768
+ detail: 'GET http://render:8790/_health (via the hub container) reports configured:true',
769
+ });
770
+ }
484
771
  steps.push({
485
772
  id: 'restore-drill',
486
773
  title: 'a backup can actually be restored',
@@ -522,6 +809,12 @@ export function operatorDuties(cfg) {
522
809
  detail: 'This runs on your infrastructure. Nothing here monitors spend.',
523
810
  },
524
811
  ];
812
+ if (cfg.canvasDomain) {
813
+ duties.push({
814
+ title: 'DNS for the canvas domain too',
815
+ detail: `${cfg.canvasDomain} must point at this machine, same as ${cfg.domain} — Caddy fetches its certificate on first request; until DNS lands, canvases are blank frames.`,
816
+ });
817
+ }
525
818
  if (cfg.s3) {
526
819
  duties.push({
527
820
  title: 'Never expire the assets/ prefix',
@@ -14,6 +14,7 @@ import {
14
14
  renderCaddyfile,
15
15
  renderCompose,
16
16
  renderEnv,
17
+ safeSeedUrl,
17
18
  validateWorkspaceConfig,
18
19
  verificationPlan,
19
20
  workspaceBaseUrl,
@@ -125,7 +126,7 @@ test('renderEnv announces that it holds secrets', () => {
125
126
  );
126
127
  assert.match(text, /Contains SECRETS/);
127
128
  assert.match(text, /Mode 0600, never committed/);
128
- assert.match(text, /^HUB_SECRET=sekrit$/m);
129
+ assert.match(text, /^HUB_SECRET='sekrit'$/m);
129
130
  // The bootstrap password is flagged as temporary rather than left to linger.
130
131
  assert.match(text, /change it after, then remove this line/);
131
132
  });
@@ -381,3 +382,216 @@ test('MAUDE_SEED_REPO crosses into the container when one is configured', () =>
381
382
  const without = renderCompose(ok(BASE));
382
383
  assert.ok(!/MAUDE_SEED_REPO/.test(without), 'no seed configured ⇒ no empty variable to misread');
383
384
  });
385
+
386
+ // ------------------------------------------- the backup namespace (Phase 0 F3)
387
+
388
+ test('a NEW render derives a backup namespace from the address', () => {
389
+ // Nothing here mentioned MAUDE_BACKUP_PREFIX before: only the CELL entrypoint
390
+ // set it, so a self-hosted workspace backed up to the bucket ROOT by
391
+ // construction, and two hubs on one bucket shared one keyspace.
392
+ const cfg = ok({ ...BASE, s3: S3 });
393
+ assert.equal(cfg.backupPrefix, 'design.acme.com');
394
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
395
+ assert.match(env, /MAUDE_BACKUP_PREFIX='design\.acme\.com'/);
396
+ });
397
+
398
+ test('an EXISTING deployment without a prefix is never given one', () => {
399
+ // The orphan hazard: a prefixed target lists a DISJOINT keyspace, so adding
400
+ // one on a re-render makes every existing generation invisible to
401
+ // listBackups — orphaned, unprunable, and the next lost volume sees zero
402
+ // generations and seeds over the loss. The fix would re-open the destruction
403
+ // it exists to close. `backupPrefix: null` is the caller saying "leave it".
404
+ const cfg = ok({ ...BASE, s3: S3, backupPrefix: null });
405
+ assert.equal(cfg.backupPrefix, null);
406
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
407
+ assert.ok(!/MAUDE_BACKUP_PREFIX/.test(env), 'no prefix must be written');
408
+ assert.ok(!/MAUDE_BACKUP_PREFIX/.test(renderCompose(cfg)), 'and none forwarded');
409
+ });
410
+
411
+ test('the namespace is written into .env AND forwarded to the container', () => {
412
+ // Hand-maintained lists on both sides; a var in one but not the other never
413
+ // reaches the container. That already shipped once, with MAUDE_ADMIN_PASSWORD.
414
+ assert.match(renderCompose(ok({ ...BASE, s3: S3 })), /MAUDE_BACKUP_PREFIX/);
415
+ });
416
+
417
+ test('no object storage means no namespace to write', () => {
418
+ const env = renderEnv(envEntries(ok(BASE), { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
419
+ assert.ok(!/MAUDE_BACKUP_PREFIX/.test(env));
420
+ });
421
+
422
+ test('a namespace can never escape its own keyspace', () => {
423
+ // It becomes an object-key prefix, so `/` and `..` have to be GONE rather
424
+ // than escaped — otherwise a namespace could address another hub's keys.
425
+ const cfg = ok({ ...BASE, s3: S3, backupPrefix: '../../Other Hub/' });
426
+ assert.equal(cfg.backupPrefix, 'other-hub');
427
+ assert.ok(!cfg.backupPrefix.includes('/'));
428
+ assert.ok(!cfg.backupPrefix.includes('..'));
429
+ });
430
+
431
+ // ------------------------------------------------------- BYO identity (C6)
432
+
433
+ const OIDC = {
434
+ issuer: 'https://acme.eu.auth0.com',
435
+ clientId: 'cid',
436
+ clientSecret: 'shh',
437
+ domains: 'acme.com',
438
+ };
439
+
440
+ test('OIDC reaches BOTH .env and the container', () => {
441
+ // Hand-maintained lists on both sides. A var in one but not the other never
442
+ // arrives — that already shipped once, with MAUDE_ADMIN_PASSWORD.
443
+ const cfg = ok({ ...BASE, oidc: OIDC });
444
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
445
+ const compose = renderCompose(cfg);
446
+ for (const key of [
447
+ 'HUB_OIDC_MODE',
448
+ 'HUB_OIDC_ISSUER',
449
+ 'HUB_OIDC_CLIENT_ID',
450
+ 'HUB_OIDC_CLIENT_SECRET',
451
+ 'HUB_OIDC_ALLOWED_DOMAINS',
452
+ ]) {
453
+ assert.ok(env.includes(`${key}=`), `${key} missing from .env`);
454
+ assert.match(compose, new RegExp(`${key}: \\$\\{${key}\\}`), `${key} not forwarded`);
455
+ }
456
+ });
457
+
458
+ test('the allowed-domain list is required — it is a filter, never a grant', () => {
459
+ const r = validateWorkspaceConfig({ ...BASE, oidc: { ...OIDC, domains: '' } });
460
+ assert.match(r.errors.join(' '), /oidc.domains is required/);
461
+ });
462
+
463
+ test('an unrecognised mode falls back to hybrid rather than to strict', () => {
464
+ // Getting this backwards would lock an operator out of their own box on a
465
+ // typo. hybrid keeps the password door open.
466
+ assert.equal(ok({ ...BASE, oidc: { ...OIDC, mode: 'stric' } }).oidc.mode, 'hybrid');
467
+ assert.equal(ok({ ...BASE, oidc: { ...OIDC, mode: 'strict' } }).oidc.mode, 'strict');
468
+ });
469
+
470
+ test('no OIDC configured leaves no empty variables to misread', () => {
471
+ const env = renderEnv(envEntries(ok(BASE), { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
472
+ assert.ok(!/HUB_OIDC/.test(env));
473
+ assert.ok(!/HUB_OIDC/.test(renderCompose(ok(BASE))));
474
+ });
475
+
476
+ test('.env values are quoted, and a newline in a secret is refused (F8)', () => {
477
+ // A `$` must not be re-interpolated by compose, and a newline must not inject
478
+ // an extra line that a re-run would then persist.
479
+ const env = renderEnv(
480
+ envEntries(ok({ ...BASE, s3: { ...(OIDC && S3), secretAccessKey: 'a$b' } }), {
481
+ hubSecret: 'x',
482
+ adminPassword: 'y'.repeat(12),
483
+ })
484
+ );
485
+ assert.match(env, /MAUDE_S3_SECRET_ACCESS_KEY='a\$b'/, 'a $ is single-quoted, not interpolated');
486
+ assert.throws(
487
+ () => renderEnv([{ key: 'X', value: 'a\nMAUDE_ALLOW_EMPTY_START=1' }]),
488
+ /newline or control character/
489
+ );
490
+ });
491
+
492
+ // M3 (AWS spike, 2026-08-20) — the seed URL carries a live GitHub PAT in
493
+ // userinfo, and `workspace-up` printed it verbatim, `--dry-run` included. The
494
+ // token reached SSM command history, CloudTrail and a session transcript, and
495
+ // had to be revoked. `.env` is 0600; stdout is not.
496
+ test('a seed URL never carries its credential into anything printable', () => {
497
+ assert.equal(
498
+ safeSeedUrl('https://x-access-token:SECRET123@github.com/o/r.git'),
499
+ 'https://***@github.com/o/r.git'
500
+ );
501
+ // Nothing to hide, nothing changed — the operator still reads host and path.
502
+ assert.equal(safeSeedUrl('https://github.com/o/r.git'), 'https://github.com/o/r.git');
503
+ assert.equal(safeSeedUrl('git@github.com:o/r.git'), 'git@github.com:o/r.git');
504
+ assert.equal(safeSeedUrl(null), null);
505
+ assert.equal(safeSeedUrl(''), null);
506
+ // Unparseable is refused wholesale rather than echoed on the chance it is clean.
507
+ assert.equal(safeSeedUrl('https://user:pw@ho st/r.git'), '<unparseable seed url>');
508
+ });
509
+
510
+ // feature-cloud-export-render-workers (DDR-230) — the optional render sidecar.
511
+ test('--render wires the sidecar end to end; without it nothing render-shaped appears', () => {
512
+ const cfg = ok({ ...BASE, render: true });
513
+ const yaml = renderCompose(cfg);
514
+ assert.match(yaml, /ghcr\.io\/1agh\/maude-render/, 'the sidecar service renders');
515
+ assert.match(yaml, /MAUDE_RENDER_CANVAS_ORIGINS: http:\/\/hub:1234/, 'origin allowlist pins the hub');
516
+ assert.match(yaml, /MAUDE_RENDER_URL/, 'the hub is told where to dispatch');
517
+ const env = renderEnv(
518
+ envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12), renderSecret: 'rsec' })
519
+ );
520
+ assert.match(env, /MAUDE_RENDER_SECRET='?rsec'?/, 'its own secret, in .env');
521
+ assert.match(env, /MAUDE_RENDER_URL=/, '.env carries the dispatch URL');
522
+ assert.match(env, /MAUDE_RENDER_CANVAS_BASE=/, '.env carries the canvas base');
523
+ assert.ok(
524
+ verificationPlan(cfg).some((s) => s.id === 'render-health'),
525
+ 'the sidecar must prove itself before the run says it worked'
526
+ );
527
+
528
+ const plain = ok(BASE);
529
+ assert.ok(!renderCompose(plain).includes('maude-render'), 'no sidecar without --render');
530
+ assert.ok(
531
+ !verificationPlan(plain).some((s) => s.id === 'render-health'),
532
+ 'no phantom verification step without the sidecar'
533
+ );
534
+ });
535
+
536
+ // M7 (AWS spike, 2026-08-20) — the studio splits the canvas iframe onto its
537
+ // own origin (DDR-054), the hub routes it by Host, the child env forwards
538
+ // MAUDE_PUBLIC_CANVAS_ORIGIN — every layer was built, and NO deployment path
539
+ // ever set the variable. The stack came up, all eight verification steps
540
+ // passed, and every canvas was a blank frame pointing at
541
+ // `http://localhost:<container port>`. The spike read that as "workspaces
542
+ // cannot render by design" and planned around it.
543
+ test('a canvas domain renders the full chain: .env, compose passthrough, Caddy site', () => {
544
+ const cfg = ok({ ...BASE, canvasDomain: 'canvas.acme.com' });
545
+ assert.equal(cfg.canvasDomain, 'canvas.acme.com');
546
+
547
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
548
+ assert.match(env, /CANVAS_DOMAIN='canvas\.acme\.com'/);
549
+ assert.match(env, /MAUDE_PUBLIC_CANVAS_ORIGIN='https:\/\/canvas\.acme\.com'/);
550
+
551
+ // Rendered into .env AND forwarded to the hub container — the drift between
552
+ // those two lists is the exact shape that shipped MAUDE_ADMIN_PASSWORD half-wired.
553
+ const compose = renderCompose(cfg);
554
+ assert.match(compose, /MAUDE_PUBLIC_CANVAS_ORIGIN: \$\{MAUDE_PUBLIC_CANVAS_ORIGIN\}/);
555
+ assert.match(compose, /CANVAS_DOMAIN: \$\{CANVAS_DOMAIN\}/);
556
+
557
+ // Caddy serves the second hostname; the hub routes by Host, so the block
558
+ // needs no port knowledge — but without it there is no name and no cert.
559
+ const caddy = renderCaddyfile(cfg);
560
+ assert.match(caddy, /\{\$CANVAS_DOMAIN\} \{/);
561
+ });
562
+
563
+ test('no canvas domain leaves no empty canvas variables to misread', () => {
564
+ const cfg = ok(BASE);
565
+ assert.equal(cfg.canvasDomain, null);
566
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
567
+ assert.ok(!/CANVAS_DOMAIN|MAUDE_PUBLIC_CANVAS_ORIGIN/.test(env));
568
+ assert.ok(!/CANVAS_DOMAIN/.test(renderCompose(cfg)));
569
+ assert.ok(!/CANVAS_DOMAIN/.test(renderCaddyfile(cfg)));
570
+ });
571
+
572
+ test('the canvas domain must be a REAL second name — same-origin defeats the split', () => {
573
+ const dup = validateWorkspaceConfig({ ...BASE, canvasDomain: BASE.domain });
574
+ assert.equal(dup.ok, false);
575
+ assert.ok(dup.errors.some((e) => /must differ from domain/.test(e)));
576
+
577
+ const bad = validateWorkspaceConfig({ ...BASE, canvasDomain: 'not a hostname' });
578
+ assert.equal(bad.ok, false);
579
+
580
+ // Scheme + trailing junk normalize away, same as the main domain.
581
+ const cfg = ok({ ...BASE, canvasDomain: 'https://Canvas.Acme.com/' });
582
+ assert.equal(cfg.canvasDomain, 'canvas.acme.com');
583
+ });
584
+
585
+ test('local mode keeps the canvas name loopback-only and plain HTTP', () => {
586
+ const cfg = ok({ local: true, adminEmail: BASE.adminEmail, canvasDomain: 'canvas.localhost' });
587
+ const env = renderEnv(envEntries(cfg, { hubSecret: 'x', adminPassword: 'y'.repeat(12) }));
588
+ assert.match(env, /MAUDE_PUBLIC_CANVAS_ORIGIN='http:\/\/canvas\.localhost'/);
589
+ assert.match(renderCaddyfile(cfg), /http:\/\/\{\$CANVAS_DOMAIN\} \{/);
590
+
591
+ const bad = validateWorkspaceConfig({
592
+ local: true,
593
+ adminEmail: BASE.adminEmail,
594
+ canvasDomain: 'canvas.acme.com',
595
+ });
596
+ assert.equal(bad.ok, false);
597
+ });