@1agh/maude 0.47.0 → 0.49.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 (58) hide show
  1. package/README.md +7 -6
  2. package/apps/studio/acp/bridge.ts +8 -1
  3. package/apps/studio/acp/plugin-bootstrap.ts +15 -1
  4. package/apps/studio/api.ts +18 -2
  5. package/apps/studio/assets-s3.ts +291 -0
  6. package/apps/studio/build.ts +1 -1
  7. package/apps/studio/client/export-center.jsx +7 -6
  8. package/apps/studio/client/github.js +11 -4
  9. package/apps/studio/collab/origins.ts +150 -0
  10. package/apps/studio/collab/protocol.ts +36 -9
  11. package/apps/studio/collab/room.ts +124 -0
  12. package/apps/studio/context.ts +9 -0
  13. package/apps/studio/dist/client.bundle.js +282 -282
  14. package/apps/studio/dist/comment-mount.js +2 -2
  15. package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
  16. package/apps/studio/examples/perf-100-artboards.tsx +1 -1
  17. package/apps/studio/exporters/pdf.ts +35 -1
  18. package/apps/studio/git/service.ts +4 -1
  19. package/apps/studio/http.ts +45 -0
  20. package/apps/studio/paths.ts +37 -1
  21. package/apps/studio/server.ts +75 -2
  22. package/apps/studio/sync/autocommit.ts +299 -0
  23. package/apps/studio/sync/doc-name.ts +228 -0
  24. package/apps/studio/sync/index.ts +95 -1
  25. package/apps/studio/sync/workspace-signin.ts +301 -0
  26. package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
  27. package/apps/studio/test/acp-session-plugins.test.ts +6 -0
  28. package/apps/studio/test/assets-s3.test.ts +249 -0
  29. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  30. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  31. package/apps/studio/test/exporters/pdf.test.ts +33 -1
  32. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  33. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  34. package/apps/studio/test/workspace-containment.test.ts +258 -0
  35. package/apps/studio/test/workspace-signin.test.ts +270 -0
  36. package/apps/studio/use-collab.tsx +28 -1
  37. package/apps/studio/workspace-mode.ts +210 -0
  38. package/apps/studio/ws.ts +11 -2
  39. package/cli/bin/maude.mjs +1 -0
  40. package/cli/commands/hub-workspace.mjs +341 -0
  41. package/cli/commands/hub.mjs +325 -3
  42. package/cli/commands/hub.test.mjs +14 -1
  43. package/cli/commands/init.mjs +80 -3
  44. package/cli/commands/kg.mjs +368 -0
  45. package/cli/commands/kg.test.mjs +118 -0
  46. package/cli/lib/cell-plan.mjs +302 -0
  47. package/cli/lib/cell-plan.test.mjs +225 -0
  48. package/cli/lib/ddr-to-kgai.mjs +648 -0
  49. package/cli/lib/ddr-to-kgai.test.mjs +99 -0
  50. package/cli/lib/flow-design-integration.test.mjs +2 -2
  51. package/cli/lib/gitignore-block.mjs +16 -1
  52. package/cli/lib/plugin-name-namespace.test.mjs +71 -0
  53. package/cli/lib/workspace-plan.mjs +422 -0
  54. package/cli/lib/workspace-plan.test.mjs +223 -0
  55. package/package.json +8 -8
  56. package/plugins/design/dependencies.json +17 -0
  57. package/plugins/flow/.claude-plugin/config.schema.json +66 -0
  58. package/plugins/flow/dependencies.json +17 -0
@@ -0,0 +1,99 @@
1
+ import assert from 'node:assert/strict';
2
+ import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { test } from 'node:test';
6
+ import { buildDdrBatch } from './ddr-to-kgai.mjs';
7
+
8
+ function fixtureDir(files) {
9
+ const dir = mkdtempSync(join(tmpdir(), 'ddr-'));
10
+ mkdirSync(dir, { recursive: true });
11
+ for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body);
12
+ return dir;
13
+ }
14
+
15
+ const DDR1 = `# DDR-001: First decision
16
+
17
+ **Status:** Accepted
18
+ **Date:** 2026-01-01
19
+ **Tags:** infra, monorepo, ci
20
+
21
+ ## Context
22
+ The problem is X.
23
+
24
+ ## Decision
25
+ We pick A because it is simplest and safest.
26
+ `;
27
+
28
+ const DDR2 = `# DDR-002: Second decision
29
+
30
+ **Status:** Accepted
31
+ **Date:** 2026-02-02
32
+ **Tags:** auth
33
+
34
+ **Supersedes:** DDR-001
35
+ **Related:** DDR-003
36
+
37
+ ## Decision
38
+ We replace the earlier approach. See DDR-001 for history and DDR-003 alongside.
39
+ `;
40
+
41
+ test('each DDR yields one decision with area + title + date + rationale', () => {
42
+ const { batch, stats } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1 }), {});
43
+ assert.equal(batch.decisions.length, 1);
44
+ const d = batch.decisions[0];
45
+ assert.equal(d.title, 'First decision');
46
+ assert.equal(d.date, '2026-01-01');
47
+ assert.match(d.rationale, /simplest and safest/);
48
+ // area = primary tag, decision element, ABOUT link
49
+ const kinds = d.mutations.map((m) => `${m.op}:${m.kind || m.link || ''}`);
50
+ assert.ok(kinds.includes('upsert_element:area'));
51
+ assert.ok(kinds.includes('upsert_element:decision'));
52
+ assert.ok(kinds.includes('add_link:ABOUT'));
53
+ assert.equal(stats.files, 1);
54
+ });
55
+
56
+ test('secondary tags become topic elements + TOUCHES links', () => {
57
+ const { batch } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1 }), {});
58
+ const muts = batch.decisions[0].mutations;
59
+ const topics = muts
60
+ .filter((m) => m.op === 'upsert_element' && m.kind === 'topic')
61
+ .map((m) => m.name);
62
+ assert.deepEqual(topics.sort(), ['ci', 'monorepo']); // primary 'infra' is the area, not a topic
63
+ assert.ok(muts.some((m) => m.link === 'TOUCHES'));
64
+ });
65
+
66
+ test('typed Supersedes marker wins; bare mention does not duplicate it', () => {
67
+ const { batch } = buildDdrBatch(fixtureDir({ 'DDR-002-y.md': DDR2 }), {});
68
+ const links = batch.decisions[0].mutations.filter((m) => m.op === 'add_link');
69
+ const sup = links.filter((l) => l.link === 'SUPERSEDES' && l.to === 'decision:DDR-001');
70
+ assert.equal(
71
+ sup.length,
72
+ 1,
73
+ 'exactly one SUPERSEDES → DDR-001 (typed marker, not duplicated by the bare mention)'
74
+ );
75
+ // DDR-003 came from **Related:** → REFERENCES
76
+ assert.ok(links.some((l) => l.link === 'REFERENCES' && l.to === 'decision:DDR-003'));
77
+ });
78
+
79
+ test('scope tags are added per decision when scope is set (model A)', () => {
80
+ const { batch } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1 }), {
81
+ repo: 'maude',
82
+ dept: 'dev',
83
+ });
84
+ const muts = batch.decisions[0].mutations;
85
+ assert.ok(muts.some((m) => m.op === 'upsert_element' && m.kind === 'repo' && m.name === 'maude'));
86
+ assert.ok(muts.some((m) => m.link === 'IN_REPO' && m.to === 'repo:maude'));
87
+ assert.ok(muts.some((m) => m.link === 'IN_DEPT' && m.to === 'dept:dev'));
88
+ });
89
+
90
+ test('no scope ⇒ no scope mutations', () => {
91
+ const { batch } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1 }), {});
92
+ const muts = batch.decisions[0].mutations;
93
+ assert.ok(!muts.some((m) => m.link === 'IN_REPO' || m.link === 'IN_DEPT'));
94
+ });
95
+
96
+ test('non-DDR files are ignored', () => {
97
+ const { batch } = buildDdrBatch(fixtureDir({ 'DDR-001-x.md': DDR1, 'README.md': '# index' }), {});
98
+ assert.equal(batch.decisions.length, 1);
99
+ });
@@ -158,8 +158,8 @@ test('wiring: ddr-keeper + record-ddr have the canvas-reference prompt', () => {
158
158
  });
159
159
 
160
160
  test('wiring: DDR-066 exists and is indexed', () => {
161
- const ddr = read('.ai/decisions/DDR-066-soft-handoff-prompt-in-flow-done.md');
161
+ const ddr = read('.ai/archive/decisions/DDR-066-soft-handoff-prompt-in-flow-done.md');
162
162
  assert.match(ddr, /soft prompt/i);
163
- const index = read('.ai/decisions/README.md');
163
+ const index = read('.ai/archive/decisions/README.md');
164
164
  assert.match(index, /DDR-066/);
165
165
  });
@@ -20,7 +20,7 @@ export const END_MARKER = '# maude:end';
20
20
  * The runtime paths Maude ignores under the design root, relative to repo root.
21
21
  * `designRel` defaults to `.design` (the only design root in v1.1 defaults).
22
22
  */
23
- export function buildBlock(designRel = '.design') {
23
+ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
24
24
  const root = designRel.replace(/\/+$/, '');
25
25
  // The canonical IGNORED set is the DDR-115 runtime-state taxonomy — kept in
26
26
  // lockstep with `apps/studio/git/service.ts` (isMaudeRuntimeState backstop)
@@ -51,6 +51,21 @@ export function buildBlock(designRel = '.design') {
51
51
  `${root}/_chat/`, // ACP transcripts (per-machine)
52
52
  `${root}/_untrusted/`, // hub-synced untrusted file mirror (DDR-054)
53
53
  `${root}/_comments/`, // hub-sync-only collab comments (DDR-102/DDR-115 — never git)
54
+ // Cloud Phase 3 Task 2 — when an S3/R2 asset lane is configured, binary
55
+ // media lives in the bucket and must STOP entering git: a 60 MB clip is
56
+ // 60 MB in every clone forever, and delta compression does nothing for it.
57
+ // Off by default, because without a bucket the only copy of an asset is the
58
+ // one in the repo and ignoring it would delete people's media on the next
59
+ // clean checkout.
60
+ ...(s3Assets
61
+ ? [
62
+ `${root}/assets/`, // binary media lives in the S3/R2 bucket (DDR-192 §1)
63
+ ]
64
+ : []),
65
+ // kgai knowledge-graph projection (repo-root, NOT under the design root) —
66
+ // per-machine append-only store, rebuilds from the remote on `kg sync`
67
+ // (feature-kgai-ecosystem-integration, DDR-115). kgai itself also ignores it.
68
+ '.kgai/',
54
69
  END_MARKER,
55
70
  ];
56
71
  return `${lines.join('\n')}\n`;
@@ -0,0 +1,71 @@
1
+ // DDR-006 (superseded) regression guard.
2
+ //
3
+ // Claude Code now prepends the `<plugin>:` namespace to every plugin
4
+ // command/agent/skill itself (fixed upstream in Claude Code 2.1.216 — see
5
+ // changelog "Fixed plugin skills with a `name` frontmatter field losing
6
+ // their plugin prefix in slash-command autocomplete" — and Claude Code
7
+ // 2.1.218 explicitly reserves `:` in agent names for that namespacing).
8
+ // Baking the prefix into our own `name:` frontmatter, which DDR-006
9
+ // (2026-05-13) required as a workaround for the OLD (opposite) bug, now
10
+ // produces a doubled prefix — `/design:design:new`, `flow:flow:a11y-auditor`.
11
+ //
12
+ // Two invariants this guards:
13
+ // 1. `name:` frontmatter in plugins/{design,flow}/{commands,agents,skills}/
14
+ // must be the BARE slug — no `design:`/`flow:` prefix.
15
+ // 2. `subagent_type:` references to our OWN plugin agents must carry
16
+ // exactly ONE `<plugin>:` prefix (the form the runtime's own namespace
17
+ // + our bare `name:` resolves to) — never zero (broken lookup against
18
+ // a namespaced registry) and never two (stale pre-fix leftover).
19
+
20
+ import assert from 'node:assert/strict';
21
+ import { execSync } from 'node:child_process';
22
+ import { test } from 'node:test';
23
+
24
+ function grep(pattern, extraArgs = '') {
25
+ const out = execSync(
26
+ `grep -rn ${extraArgs} '${pattern}' plugins/design plugins/flow --include='*.md' || true`,
27
+ { encoding: 'utf8' }
28
+ );
29
+ return out.trim().split('\n').filter(Boolean);
30
+ }
31
+
32
+ test('no plugin name: frontmatter carries a redundant design:/flow: prefix', () => {
33
+ const offenders = grep('^name: \\(design\\|flow\\):');
34
+ assert.deepEqual(
35
+ offenders,
36
+ [],
37
+ `These files declare name: with the plugin prefix baked in — Claude Code adds the plugin namespace itself now, so this doubles into e.g. /design:design:new (DDR-006 superseded):\n ${offenders.join('\n ')}`
38
+ );
39
+ });
40
+
41
+ test('no doubled design:design: / flow:flow: namespace anywhere in plugin markdown', () => {
42
+ const offenders = grep('design:design:\\|flow:flow:');
43
+ assert.deepEqual(
44
+ offenders,
45
+ [],
46
+ `Doubled plugin namespace found — strip one level (DDR-006 superseded):\n ${offenders.join('\n ')}`
47
+ );
48
+ });
49
+
50
+ test('subagent_type references to our own plugin agents carry exactly one <plugin>: prefix', () => {
51
+ // Bare `subagent_type: <slug>` where <slug> looks like one of our
52
+ // agent-style names (-critic/-agent/-auditor/-hacker/-keeper/-director/
53
+ // -analyst suffix) but has no plugin prefix — excludes Claude Code
54
+ // built-ins (e.g. code-simplifier, which is not one of our plugin agents)
55
+ // and the `"<critic-name>"` template placeholder.
56
+ const bareOffenders = grep(
57
+ 'subagent_type:.*"\\?[a-z][a-z0-9-]*\\(-critic\\|-agent\\|-auditor\\|-hacker\\|-keeper\\|-director\\|-analyst\\)"\\?'
58
+ ).filter((line) => !/design:|flow:|<critic-name>|code-simplifier/.test(line));
59
+ assert.deepEqual(
60
+ bareOffenders,
61
+ [],
62
+ `These subagent_type references to a plugin-owned agent are missing the <plugin>: prefix and will fail to resolve against the namespaced registry:\n ${bareOffenders.join('\n ')}`
63
+ );
64
+
65
+ const doubledOffenders = grep('subagent_type:.*\\(design:design:\\|flow:flow:\\)');
66
+ assert.deepEqual(
67
+ doubledOffenders,
68
+ [],
69
+ `These subagent_type references are double-prefixed (DDR-006 superseded):\n ${doubledOffenders.join('\n ')}`
70
+ );
71
+ });
@@ -0,0 +1,422 @@
1
+ // `maude hub workspace-up` — the pure planning layer. Cloud Phase 4 Task 1.
2
+ //
3
+ // Everything here is a FUNCTION OF ITS INPUTS: validate a config, render the
4
+ // files, decide what verification must pass, produce the operator card. No
5
+ // disk, no network, no Docker. The command module (`hub-workspace.mjs`) does
6
+ // the effects.
7
+ //
8
+ // That split is not tidiness. A provisioner's failure modes — a bad domain
9
+ // silently rendering a broken Caddyfile, a missing no-expiry policy quietly
10
+ // scheduling someone's media for deletion, an "it worked!" printed before
11
+ // anything round-tripped — are all decisions, and decisions are testable
12
+ // without a VPS. What genuinely needs Docker is then a thin, boring shell.
13
+ //
14
+ // THE BREAKER TRAP THIS FILE EXISTS TO AVOID: a one-command provisioner that
15
+ // prints "done" implies it owns the thing forever. It does not. It scaffolds
16
+ // and verifies once. Key rotation, backups, upgrades and the bill stay with
17
+ // the operator, and `operatorDuties()` says so on every successful run — a
18
+ // promise nobody made is a promise nobody breaks.
19
+
20
+ const DOMAIN_RE = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;
21
+ const BUCKET_RE = /^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/;
22
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
23
+
24
+ /**
25
+ * @typedef {object} WorkspaceConfig
26
+ * @property {string} domain public hostname, e.g. design.acme.com
27
+ * @property {string} acmeEmail Let's Encrypt contact
28
+ * @property {string} adminEmail the first user
29
+ * @property {string} [adminPassword] omitted → the caller must supply one
30
+ * @property {object} [s3] { endpoint, bucket, accessKeyId, secretAccessKey, region }
31
+ * @property {boolean} [devMinio] run a local MinIO under the dev profile
32
+ * @property {string} [seedRepo] git URL to seed from; omitted → start fresh
33
+ * @property {string} [imageTag]
34
+ */
35
+
36
+ /**
37
+ * Validate a workspace config. Returns `{ ok, errors, config }` — a LIST of
38
+ * problems, not the first one: someone filling this in wants to fix everything
39
+ * in one pass, not to play whack-a-mole with a wizard.
40
+ */
41
+ export function validateWorkspaceConfig(raw = {}) {
42
+ const errors = [];
43
+ const cfg = { ...raw };
44
+
45
+ cfg.domain = String(raw.domain ?? '')
46
+ .trim()
47
+ .toLowerCase()
48
+ .replace(/^https?:\/\//, '')
49
+ .replace(/\/.*$/, '');
50
+ if (!cfg.domain) errors.push('domain is required (the public hostname, e.g. design.acme.com)');
51
+ else if (!DOMAIN_RE.test(cfg.domain))
52
+ errors.push(`domain "${cfg.domain}" is not a valid hostname`);
53
+ else if (!cfg.domain.includes('.')) errors.push('domain must be fully qualified');
54
+
55
+ cfg.acmeEmail = String(raw.acmeEmail ?? '').trim();
56
+ if (!cfg.acmeEmail) errors.push('acmeEmail is required (Let’s Encrypt expiry notices)');
57
+ else if (!EMAIL_RE.test(cfg.acmeEmail))
58
+ errors.push(`acmeEmail "${cfg.acmeEmail}" is not an email`);
59
+
60
+ cfg.adminEmail = String(raw.adminEmail ?? '')
61
+ .trim()
62
+ .toLowerCase();
63
+ if (!cfg.adminEmail) errors.push('adminEmail is required (the first person who can sign in)');
64
+ else if (!EMAIL_RE.test(cfg.adminEmail))
65
+ errors.push(`adminEmail "${cfg.adminEmail}" is not an email`);
66
+
67
+ if (raw.adminPassword !== undefined) {
68
+ if (typeof raw.adminPassword !== 'string' || raw.adminPassword.length < 12) {
69
+ errors.push('adminPassword must be at least 12 characters');
70
+ }
71
+ }
72
+
73
+ cfg.devMinio = raw.devMinio === true;
74
+ if (raw.s3) {
75
+ const s3 = { ...raw.s3 };
76
+ s3.bucket = String(s3.bucket ?? '').trim();
77
+ s3.endpoint = String(s3.endpoint ?? '')
78
+ .trim()
79
+ .replace(/\/+$/, '');
80
+ if (!s3.bucket) errors.push('s3.bucket is required when s3 is configured');
81
+ else if (!BUCKET_RE.test(s3.bucket))
82
+ errors.push(`s3.bucket "${s3.bucket}" is not a valid name`);
83
+ if (!s3.endpoint) errors.push('s3.endpoint is required when s3 is configured');
84
+ else if (!/^https?:\/\//.test(s3.endpoint))
85
+ errors.push('s3.endpoint must start with http:// or https://');
86
+ if (!s3.accessKeyId) errors.push('s3.accessKeyId is required when s3 is configured');
87
+ if (!s3.secretAccessKey) errors.push('s3.secretAccessKey is required when s3 is configured');
88
+ s3.region = s3.region || 'auto';
89
+ cfg.s3 = s3;
90
+ } else if (cfg.devMinio) {
91
+ // The dev profile stands up its own MinIO, so it can supply its own
92
+ // credentials — but they are DEV credentials and the render says so.
93
+ cfg.s3 = {
94
+ endpoint: 'http://minio:9000',
95
+ bucket: 'maude-assets',
96
+ accessKeyId: 'maude-dev',
97
+ secretAccessKey: 'maude-dev-secret',
98
+ region: 'auto',
99
+ dev: true,
100
+ };
101
+ }
102
+
103
+ if (raw.seedRepo !== undefined && raw.seedRepo !== null && String(raw.seedRepo).trim() !== '') {
104
+ const seed = String(raw.seedRepo).trim();
105
+ if (!/^(https?:\/\/|git@|ssh:\/\/)/.test(seed)) {
106
+ errors.push('seedRepo must be an https://, ssh:// or git@ URL');
107
+ }
108
+ cfg.seedRepo = seed;
109
+ } else {
110
+ cfg.seedRepo = null;
111
+ }
112
+
113
+ cfg.imageTag = String(raw.imageTag ?? 'latest').trim() || 'latest';
114
+
115
+ return { ok: errors.length === 0, errors, config: cfg };
116
+ }
117
+
118
+ /**
119
+ * The `.env` a workspace deployment needs. Returned as an ordered array of
120
+ * `{ key, value, comment }` so the renderer can annotate and a test can assert
121
+ * a secret is present without string-matching a whole file.
122
+ *
123
+ * `HUB_TRUSTED_PROXIES` is set unconditionally: Caddy fronts the hub, so
124
+ * without it every client shares one rate-limit bucket and a single attacker's
125
+ * login flood limits everybody (DDR-194 §4).
126
+ */
127
+ export function envEntries(cfg, { hubSecret, adminPassword }) {
128
+ const entries = [
129
+ {
130
+ key: 'PUBLIC_DOMAIN',
131
+ value: cfg.domain,
132
+ comment: 'public hostname; Caddy fetches a cert for it',
133
+ },
134
+ { key: 'ACME_EMAIL', value: cfg.acmeEmail, comment: "Let's Encrypt expiry notices" },
135
+ {
136
+ key: 'HUB_SECRET',
137
+ value: hubSecret,
138
+ comment: 'operator credential — rotate on staff change',
139
+ },
140
+ {
141
+ key: 'HUB_TRUSTED_PROXIES',
142
+ value: '172.16.0.0/12,10.0.0.0/8,192.168.0.0/16,fd00::/8',
143
+ comment: 'Caddy fronts the hub; without this every client shares one rate-limit bucket',
144
+ },
145
+ { key: 'HUB_WORKSPACE_MODE', value: '1', comment: 'users required; permissive dev auth off' },
146
+ {
147
+ key: 'MAUDE_WORKSPACE_MODE',
148
+ value: '1',
149
+ comment: 'containment invariant enforced (DDR-193 §2)',
150
+ },
151
+ {
152
+ key: 'MAUDE_ADMIN_EMAIL',
153
+ value: cfg.adminEmail,
154
+ comment: 'first user, created on first boot',
155
+ },
156
+ ];
157
+ if (adminPassword) {
158
+ entries.push({
159
+ key: 'MAUDE_ADMIN_PASSWORD',
160
+ value: adminPassword,
161
+ comment: 'first sign-in; change it after, then remove this line',
162
+ });
163
+ }
164
+ if (cfg.s3) {
165
+ entries.push(
166
+ { key: 'MAUDE_S3_ENDPOINT', value: cfg.s3.endpoint },
167
+ { key: 'MAUDE_S3_BUCKET', value: cfg.s3.bucket },
168
+ { key: 'MAUDE_S3_ACCESS_KEY_ID', value: cfg.s3.accessKeyId },
169
+ { key: 'MAUDE_S3_SECRET_ACCESS_KEY', value: cfg.s3.secretAccessKey },
170
+ { key: 'MAUDE_S3_REGION', value: cfg.s3.region }
171
+ );
172
+ }
173
+ if (cfg.seedRepo) {
174
+ entries.push({ key: 'MAUDE_SEED_REPO', value: cfg.seedRepo, comment: 'cloned on first boot' });
175
+ }
176
+ entries.push({
177
+ key: 'MAUDE_IMAGE_TAG',
178
+ value: cfg.imageTag,
179
+ comment: 'pin this to a release before you rely on it',
180
+ });
181
+ return entries;
182
+ }
183
+
184
+ /** Render `.env`. Written at mode 0600 by the caller — it holds two secrets. */
185
+ export function renderEnv(entries) {
186
+ const lines = [
187
+ '# Maude workspace — generated by `maude hub workspace-up`.',
188
+ '# Contains SECRETS. Mode 0600, never committed.',
189
+ '',
190
+ ];
191
+ for (const e of entries) {
192
+ if (e.comment) lines.push(`# ${e.comment}`);
193
+ lines.push(`${e.key}=${e.value}`);
194
+ lines.push('');
195
+ }
196
+ return `${lines.join('\n').trimEnd()}\n`;
197
+ }
198
+
199
+ /**
200
+ * The compose stack: hub + Caddy, MinIO only under the `dev` profile.
201
+ *
202
+ * MinIO is behind a profile rather than commented out, because a commented
203
+ * service is one someone uncomments in production. `--profile dev` is a thing
204
+ * you have to mean.
205
+ */
206
+ export function renderCompose(cfg) {
207
+ const envLines = (keys) => keys.map((k) => ` ${k}: \${${k}}`).join('\n');
208
+ const hubEnv = [
209
+ 'HUB_SECRET',
210
+ 'HUB_TRUSTED_PROXIES',
211
+ 'HUB_WORKSPACE_MODE',
212
+ 'MAUDE_WORKSPACE_MODE',
213
+ 'MAUDE_ADMIN_EMAIL',
214
+ ...(cfg.s3
215
+ ? [
216
+ 'MAUDE_S3_ENDPOINT',
217
+ 'MAUDE_S3_BUCKET',
218
+ 'MAUDE_S3_ACCESS_KEY_ID',
219
+ 'MAUDE_S3_SECRET_ACCESS_KEY',
220
+ 'MAUDE_S3_REGION',
221
+ ]
222
+ : []),
223
+ ...(cfg.seedRepo ? ['MAUDE_SEED_REPO'] : []),
224
+ ];
225
+
226
+ return `# Maude workspace — generated by \`maude hub workspace-up\`.
227
+ #
228
+ # Re-running the command regenerates this file; edit \`.env\` instead, or pass
229
+ # different flags. Bring it up with:
230
+ #
231
+ # docker compose up -d
232
+ #
233
+ # MinIO is behind the \`dev\` profile ON PURPOSE. A commented-out service is one
234
+ # somebody uncomments in production; \`--profile dev\` is a thing you have to mean.
235
+
236
+ services:
237
+ hub:
238
+ image: ghcr.io/1agh/maude-hub:\${MAUDE_IMAGE_TAG:-latest}
239
+ restart: unless-stopped
240
+ environment:
241
+ HUB_PUBLIC_URL: https://\${PUBLIC_DOMAIN}
242
+ ${envLines(hubEnv)}
243
+ volumes:
244
+ - hub-data:/data
245
+ expose:
246
+ - "1234"
247
+
248
+ caddy:
249
+ image: caddy:2-alpine
250
+ restart: unless-stopped
251
+ ports:
252
+ - "80:80"
253
+ - "443:443"
254
+ environment:
255
+ PUBLIC_DOMAIN: \${PUBLIC_DOMAIN}
256
+ ACME_EMAIL: \${ACME_EMAIL}
257
+ volumes:
258
+ - ./Caddyfile:/etc/caddy/Caddyfile:ro
259
+ - caddy-data:/data
260
+ - caddy-config:/config
261
+ depends_on:
262
+ - hub
263
+ ${
264
+ cfg.devMinio
265
+ ? `
266
+ # DEV ONLY — object storage for local testing. Never expose this publicly and
267
+ # never point a real workspace at it: the credentials below are in this file.
268
+ minio:
269
+ profiles: ["dev"]
270
+ image: minio/minio:latest
271
+ restart: unless-stopped
272
+ command: server /data --console-address ":9001"
273
+ environment:
274
+ MINIO_ROOT_USER: \${MAUDE_S3_ACCESS_KEY_ID}
275
+ MINIO_ROOT_PASSWORD: \${MAUDE_S3_SECRET_ACCESS_KEY}
276
+ volumes:
277
+ - minio-data:/data
278
+ ports:
279
+ - "9000:9000"
280
+ - "9001:9001"
281
+ `
282
+ : ''
283
+ }
284
+ volumes:
285
+ hub-data:
286
+ caddy-data:
287
+ caddy-config:${cfg.devMinio ? '\n minio-data:' : ''}
288
+ `;
289
+ }
290
+
291
+ /** Caddyfile with `trusted_proxies` wired, so XFF is honoured correctly. */
292
+ export function renderCaddyfile(cfg) {
293
+ return `# Maude workspace — generated by \`maude hub workspace-up\`.
294
+
295
+ {
296
+ email {$ACME_EMAIL}
297
+ }
298
+
299
+ {$PUBLIC_DOMAIN} {
300
+ encode zstd gzip
301
+
302
+ # The hub reads X-Forwarded-For only from addresses it trusts
303
+ # (HUB_TRUSTED_PROXIES). Caddy is on the compose network, so it must set the
304
+ # header for per-client rate limiting to work at all — without it every
305
+ # client shares one bucket and one attacker's login flood limits everybody.
306
+ reverse_proxy hub:1234 {
307
+ header_up X-Forwarded-For {remote_host}
308
+ header_up X-Forwarded-Proto {scheme}
309
+ header_up Host {host}
310
+ }
311
+ }
312
+ `;
313
+ }
314
+
315
+ /**
316
+ * The checks that must pass before this command is allowed to say it worked.
317
+ *
318
+ * Returned as data so the runner executes them uniformly and reports each by
319
+ * name. A provisioner that prints a URL without proving a round-trip has told
320
+ * the operator something it does not know.
321
+ */
322
+ export function verificationPlan(cfg) {
323
+ const steps = [
324
+ {
325
+ id: 'health',
326
+ title: 'the workspace answers',
327
+ detail: `GET https://${cfg.domain}/health returns ok`,
328
+ },
329
+ {
330
+ id: 'admin-claimed',
331
+ title: 'the operator credential works',
332
+ detail: 'the admin API accepts the generated HUB_SECRET',
333
+ },
334
+ {
335
+ id: 'user-signin',
336
+ title: 'the first person can sign in',
337
+ detail: `${cfg.adminEmail} exchanges a password for a session`,
338
+ },
339
+ {
340
+ id: 'canvas-roundtrip',
341
+ title: 'a canvas survives a round trip',
342
+ detail: 'a sentinel canvas syncs up and reads back byte-identical',
343
+ },
344
+ {
345
+ id: 'git-commit',
346
+ title: 'autosave produced a commit',
347
+ detail: 'the sentinel edit exists in the workspace git history',
348
+ },
349
+ ];
350
+ if (cfg.s3) {
351
+ steps.push(
352
+ {
353
+ id: 's3-object',
354
+ title: 'media reaches the bucket',
355
+ detail: 'a sentinel asset is retrievable from object storage',
356
+ },
357
+ {
358
+ id: 's3-no-expiry',
359
+ title: 'nothing will expire the media',
360
+ // The quiet catastrophe: a lifecycle rule on `assets/` deletes objects
361
+ // that canvases in git history still point at, with no recovery path.
362
+ detail: 'no lifecycle/expiry rule applies to the assets/ prefix',
363
+ }
364
+ );
365
+ }
366
+ steps.push({
367
+ id: 'restore-drill',
368
+ title: 'a backup can actually be restored',
369
+ detail: '`maude hub restore-drill` passes against the configured target',
370
+ });
371
+ return steps;
372
+ }
373
+
374
+ /**
375
+ * What the operator still owns. Printed on every successful run.
376
+ *
377
+ * The trap this avoids: a one-command provisioner that prints "done" implies it
378
+ * owns the deployment forever. It scaffolded and verified it, once. Saying so
379
+ * plainly is the difference between a tool that is trusted and one that is
380
+ * blamed.
381
+ */
382
+ export function operatorDuties(cfg) {
383
+ const duties = [
384
+ {
385
+ title: 'Rotate HUB_SECRET when someone leaves',
386
+ detail:
387
+ 'It is in .env and it is the operator credential. `maude hub token rotate` for peers.',
388
+ },
389
+ {
390
+ title: 'Watch the restore drill, not the backup',
391
+ detail:
392
+ 'Schedule `maude hub restore-drill`. A backup nobody has restored is a hypothesis, and an empty restore looks exactly like a working one.',
393
+ },
394
+ {
395
+ title: 'Pin the image tag before you rely on this',
396
+ detail: `MAUDE_IMAGE_TAG is "${cfg.imageTag}". \`latest\` means an unplanned upgrade on the next restart.`,
397
+ },
398
+ {
399
+ title: 'Upgrades are yours',
400
+ detail: 'Re-running workspace-up regenerates the files; it does not watch for releases.',
401
+ },
402
+ {
403
+ title: 'The bill is yours',
404
+ detail: 'This runs on your infrastructure. Nothing here monitors spend.',
405
+ },
406
+ ];
407
+ if (cfg.s3) {
408
+ duties.push({
409
+ title: 'Never expire the assets/ prefix',
410
+ detail:
411
+ 'A canvas in git history can reference media no current canvas does, so "unreferenced" never means "unreachable". An expired object is a permanently broken canvas.',
412
+ });
413
+ }
414
+ if (cfg.s3?.dev) {
415
+ duties.push({
416
+ title: 'The MinIO credentials are in this directory',
417
+ detail:
418
+ 'The dev profile is for local testing. Point a real workspace at real object storage.',
419
+ });
420
+ }
421
+ return duties;
422
+ }