@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.
- package/README.md +7 -6
- package/apps/studio/acp/bridge.ts +8 -1
- package/apps/studio/acp/plugin-bootstrap.ts +15 -1
- package/apps/studio/api.ts +18 -2
- package/apps/studio/assets-s3.ts +291 -0
- package/apps/studio/build.ts +1 -1
- package/apps/studio/client/export-center.jsx +7 -6
- package/apps/studio/client/github.js +11 -4
- package/apps/studio/collab/origins.ts +150 -0
- package/apps/studio/collab/protocol.ts +36 -9
- package/apps/studio/collab/room.ts +124 -0
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +282 -282
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/REMOTION-LICENSE.md +1 -1
- package/apps/studio/examples/perf-100-artboards.tsx +1 -1
- package/apps/studio/exporters/pdf.ts +35 -1
- package/apps/studio/git/service.ts +4 -1
- package/apps/studio/http.ts +45 -0
- package/apps/studio/paths.ts +37 -1
- package/apps/studio/server.ts +75 -2
- package/apps/studio/sync/autocommit.ts +299 -0
- package/apps/studio/sync/doc-name.ts +228 -0
- package/apps/studio/sync/index.ts +95 -1
- package/apps/studio/sync/workspace-signin.ts +301 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +34 -0
- package/apps/studio/test/acp-session-plugins.test.ts +6 -0
- package/apps/studio/test/assets-s3.test.ts +249 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
- package/apps/studio/test/collab-origin-gate.test.ts +323 -0
- package/apps/studio/test/exporters/pdf.test.ts +33 -1
- package/apps/studio/test/sync-autocommit.test.ts +334 -0
- package/apps/studio/test/sync-doc-name.test.ts +281 -0
- package/apps/studio/test/workspace-containment.test.ts +258 -0
- package/apps/studio/test/workspace-signin.test.ts +270 -0
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/workspace-mode.ts +210 -0
- package/apps/studio/ws.ts +11 -2
- package/cli/bin/maude.mjs +1 -0
- package/cli/commands/hub-workspace.mjs +341 -0
- package/cli/commands/hub.mjs +325 -3
- package/cli/commands/hub.test.mjs +14 -1
- package/cli/commands/init.mjs +80 -3
- package/cli/commands/kg.mjs +368 -0
- package/cli/commands/kg.test.mjs +118 -0
- package/cli/lib/cell-plan.mjs +302 -0
- package/cli/lib/cell-plan.test.mjs +225 -0
- package/cli/lib/ddr-to-kgai.mjs +648 -0
- package/cli/lib/ddr-to-kgai.test.mjs +99 -0
- package/cli/lib/flow-design-integration.test.mjs +2 -2
- package/cli/lib/gitignore-block.mjs +16 -1
- package/cli/lib/plugin-name-namespace.test.mjs +71 -0
- package/cli/lib/workspace-plan.mjs +422 -0
- package/cli/lib/workspace-plan.test.mjs +223 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +17 -0
- package/plugins/flow/.claude-plugin/config.schema.json +66 -0
- 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
|
+
}
|