@1agh/maude 0.48.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 (33) hide show
  1. package/apps/studio/api.ts +18 -2
  2. package/apps/studio/assets-s3.ts +291 -0
  3. package/apps/studio/collab/origins.ts +150 -0
  4. package/apps/studio/collab/protocol.ts +36 -9
  5. package/apps/studio/collab/room.ts +124 -0
  6. package/apps/studio/context.ts +9 -0
  7. package/apps/studio/dist/client.bundle.js +1 -1
  8. package/apps/studio/dist/comment-mount.js +2 -2
  9. package/apps/studio/http.ts +45 -0
  10. package/apps/studio/server.ts +75 -2
  11. package/apps/studio/sync/autocommit.ts +299 -0
  12. package/apps/studio/sync/doc-name.ts +228 -0
  13. package/apps/studio/sync/index.ts +95 -1
  14. package/apps/studio/sync/workspace-signin.ts +301 -0
  15. package/apps/studio/test/assets-s3.test.ts +249 -0
  16. package/apps/studio/test/canvas-origin-gate.test.ts +7 -0
  17. package/apps/studio/test/collab-origin-gate.test.ts +323 -0
  18. package/apps/studio/test/sync-autocommit.test.ts +334 -0
  19. package/apps/studio/test/sync-doc-name.test.ts +281 -0
  20. package/apps/studio/test/workspace-containment.test.ts +258 -0
  21. package/apps/studio/test/workspace-signin.test.ts +270 -0
  22. package/apps/studio/use-collab.tsx +28 -1
  23. package/apps/studio/workspace-mode.ts +210 -0
  24. package/apps/studio/ws.ts +11 -2
  25. package/cli/commands/hub-workspace.mjs +341 -0
  26. package/cli/commands/hub.mjs +325 -3
  27. package/cli/commands/hub.test.mjs +14 -1
  28. package/cli/lib/cell-plan.mjs +302 -0
  29. package/cli/lib/cell-plan.test.mjs +225 -0
  30. package/cli/lib/gitignore-block.mjs +12 -1
  31. package/cli/lib/workspace-plan.mjs +422 -0
  32. package/cli/lib/workspace-plan.test.mjs +223 -0
  33. package/package.json +8 -8
@@ -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
+ }
@@ -0,0 +1,223 @@
1
+ // Cloud Phase 4 Task 1 — the workspace-up planning layer.
2
+ //
3
+ // A provisioner's real failure modes are decisions, not I/O: a bad domain
4
+ // rendering a broken Caddyfile, a missing no-expiry rule quietly scheduling
5
+ // someone's media for deletion, an "it worked!" printed before anything
6
+ // round-tripped. All testable without a VPS, which is why they live here.
7
+
8
+ import assert from 'node:assert/strict';
9
+ import { test } from 'node:test';
10
+
11
+ import {
12
+ envEntries,
13
+ operatorDuties,
14
+ renderCaddyfile,
15
+ renderCompose,
16
+ renderEnv,
17
+ validateWorkspaceConfig,
18
+ verificationPlan,
19
+ } from './workspace-plan.mjs';
20
+
21
+ const BASE = {
22
+ domain: 'design.acme.com',
23
+ acmeEmail: 'ops@acme.com',
24
+ adminEmail: 'alice@acme.com',
25
+ };
26
+ const S3 = {
27
+ endpoint: 'https://acct.r2.cloudflarestorage.com',
28
+ bucket: 'acme-design-assets',
29
+ accessKeyId: 'AKIA',
30
+ secretAccessKey: 'secret',
31
+ };
32
+ const ok = (raw) => {
33
+ const r = validateWorkspaceConfig(raw);
34
+ assert.deepEqual(r.errors, [], `expected valid: ${r.errors.join('; ')}`);
35
+ return r.config;
36
+ };
37
+
38
+ // ------------------------------------------------------------- validation
39
+
40
+ test('a valid config normalizes rather than nitpicking', () => {
41
+ const cfg = ok({ ...BASE, domain: 'HTTPS://Design.Acme.com/', adminEmail: 'Alice@Acme.com' });
42
+ assert.equal(cfg.domain, 'design.acme.com');
43
+ assert.equal(cfg.adminEmail, 'alice@acme.com');
44
+ assert.equal(cfg.imageTag, 'latest');
45
+ assert.equal(cfg.seedRepo, null);
46
+ });
47
+
48
+ test('EVERY problem is reported at once, not the first one', () => {
49
+ // Someone filling this in wants to fix everything in one pass, not play
50
+ // whack-a-mole with a wizard.
51
+ const res = validateWorkspaceConfig({ domain: 'not a domain', acmeEmail: 'nope' });
52
+ assert.equal(res.ok, false);
53
+ assert.ok(res.errors.length >= 3, `expected several errors, got ${res.errors.length}`);
54
+ assert.ok(res.errors.some((e) => e.includes('domain')));
55
+ assert.ok(res.errors.some((e) => e.includes('acmeEmail')));
56
+ assert.ok(res.errors.some((e) => e.includes('adminEmail')));
57
+ });
58
+
59
+ test('a bare hostname without a dot is refused', () => {
60
+ // "localhost" or "hub" would render a Caddyfile that can never get a cert.
61
+ const res = validateWorkspaceConfig({ ...BASE, domain: 'localhost' });
62
+ assert.equal(res.ok, false);
63
+ assert.ok(res.errors.some((e) => /domain/.test(e)));
64
+ });
65
+
66
+ test('a short admin password is refused before anything is written', () => {
67
+ const res = validateWorkspaceConfig({ ...BASE, adminPassword: 'short' });
68
+ assert.equal(res.ok, false);
69
+ assert.ok(res.errors.some((e) => /at least 12/.test(e)));
70
+ });
71
+
72
+ test('partial S3 config is an error, never a half-configured lane', () => {
73
+ const res = validateWorkspaceConfig({ ...BASE, s3: { bucket: 'b' } });
74
+ assert.equal(res.ok, false);
75
+ assert.ok(res.errors.some((e) => e.includes('s3.endpoint')));
76
+ assert.ok(res.errors.some((e) => e.includes('s3.accessKeyId')));
77
+ });
78
+
79
+ test('an invalid bucket name is caught here, not by the provider later', () => {
80
+ assert.equal(
81
+ validateWorkspaceConfig({ ...BASE, s3: { ...S3, bucket: 'A_Bad_Bucket' } }).ok,
82
+ false
83
+ );
84
+ });
85
+
86
+ test('a seed repo must look like a git URL', () => {
87
+ assert.equal(validateWorkspaceConfig({ ...BASE, seedRepo: 'my-folder' }).ok, false);
88
+ assert.equal(
89
+ ok({ ...BASE, seedRepo: 'git@github.com:acme/design.git' }).seedRepo,
90
+ 'git@github.com:acme/design.git'
91
+ );
92
+ assert.equal(ok({ ...BASE, seedRepo: ' ' }).seedRepo, null, 'blank means start fresh');
93
+ });
94
+
95
+ test('devMinio supplies its own credentials and marks them as dev', () => {
96
+ const cfg = ok({ ...BASE, devMinio: true });
97
+ assert.equal(cfg.s3.dev, true);
98
+ assert.equal(cfg.s3.endpoint, 'http://minio:9000');
99
+ });
100
+
101
+ // ------------------------------------------------------------------- env
102
+
103
+ test('.env carries the trusted-proxy setting UNCONDITIONALLY', () => {
104
+ // Caddy fronts the hub. Without this every client shares one rate-limit
105
+ // bucket and one attacker's login flood limits everybody (DDR-194 §4).
106
+ const entries = envEntries(ok(BASE), { hubSecret: 'deadbeef' });
107
+ const byKey = Object.fromEntries(entries.map((e) => [e.key, e.value]));
108
+ assert.ok(byKey.HUB_TRUSTED_PROXIES.includes('172.16.0.0/12'));
109
+ assert.equal(byKey.HUB_WORKSPACE_MODE, '1');
110
+ assert.equal(byKey.MAUDE_WORKSPACE_MODE, '1', 'the containment invariant must be on');
111
+ });
112
+
113
+ test('.env includes S3 only when configured', () => {
114
+ const without = envEntries(ok(BASE), { hubSecret: 'x' }).map((e) => e.key);
115
+ assert.ok(!without.includes('MAUDE_S3_BUCKET'));
116
+ const with3 = envEntries(ok({ ...BASE, s3: S3 }), { hubSecret: 'x' }).map((e) => e.key);
117
+ assert.ok(with3.includes('MAUDE_S3_BUCKET'));
118
+ assert.ok(with3.includes('MAUDE_S3_SECRET_ACCESS_KEY'));
119
+ });
120
+
121
+ test('renderEnv announces that it holds secrets', () => {
122
+ const text = renderEnv(
123
+ envEntries(ok(BASE), { hubSecret: 'sekrit', adminPassword: 'a-good-one-12' })
124
+ );
125
+ assert.match(text, /Contains SECRETS/);
126
+ assert.match(text, /Mode 0600, never committed/);
127
+ assert.match(text, /^HUB_SECRET=sekrit$/m);
128
+ // The bootstrap password is flagged as temporary rather than left to linger.
129
+ assert.match(text, /change it after, then remove this line/);
130
+ });
131
+
132
+ // --------------------------------------------------------------- compose
133
+
134
+ test('MinIO is behind a PROFILE, not a comment', () => {
135
+ // A commented-out service is one somebody uncomments in production.
136
+ const yaml = renderCompose(ok({ ...BASE, devMinio: true }));
137
+ assert.match(yaml, /profiles: \["dev"\]/);
138
+ assert.match(yaml, /DEV ONLY/);
139
+ assert.ok(!renderCompose(ok(BASE)).includes('minio'), 'no MinIO without devMinio');
140
+ });
141
+
142
+ test('the hub is exposed, never published — Caddy is the only way in', () => {
143
+ const yaml = renderCompose(ok({ ...BASE, s3: S3 }));
144
+ const hubBlock = yaml.slice(yaml.indexOf(' hub:'), yaml.indexOf(' caddy:'));
145
+ assert.match(hubBlock, /expose:/);
146
+ assert.ok(!/^\s+ports:/m.test(hubBlock), 'the hub must not publish a port to the host');
147
+ });
148
+
149
+ test('compose passes every configured secret by reference, never inline', () => {
150
+ const yaml = renderCompose(ok({ ...BASE, s3: S3 }));
151
+ assert.match(yaml, /MAUDE_S3_SECRET_ACCESS_KEY: \$\{MAUDE_S3_SECRET_ACCESS_KEY\}/);
152
+ assert.ok(!yaml.includes('secret'), 'no literal secret value may appear in compose');
153
+ });
154
+
155
+ test('Caddy forwards the client address, or per-client rate limiting cannot work', () => {
156
+ const caddy = renderCaddyfile(ok(BASE));
157
+ assert.match(caddy, /header_up X-Forwarded-For \{remote_host\}/);
158
+ assert.match(caddy, /reverse_proxy hub:1234/);
159
+ assert.match(caddy, /\{\$PUBLIC_DOMAIN\}/);
160
+ });
161
+
162
+ // ---------------------------------------------------------- verification
163
+
164
+ test('the plan verifies a ROUND TRIP, not just liveness', () => {
165
+ // A provisioner that prints a URL without proving a round-trip has told the
166
+ // operator something it does not know.
167
+ const ids = verificationPlan(ok(BASE)).map((s) => s.id);
168
+ assert.ok(ids.includes('canvas-roundtrip'));
169
+ assert.ok(ids.includes('git-commit'));
170
+ assert.ok(ids.includes('user-signin'));
171
+ assert.ok(ids.includes('restore-drill'));
172
+ });
173
+
174
+ test('with object storage it also checks that nothing will EXPIRE the media', () => {
175
+ // The quiet catastrophe: a lifecycle rule on assets/ deletes objects that
176
+ // canvases in git history still point at, with no recovery path.
177
+ const ids = verificationPlan(ok({ ...BASE, s3: S3 })).map((s) => s.id);
178
+ assert.ok(ids.includes('s3-object'));
179
+ assert.ok(ids.includes('s3-no-expiry'));
180
+ assert.ok(!verificationPlan(ok(BASE)).some((s) => s.id === 's3-no-expiry'));
181
+ });
182
+
183
+ test('every step states what it checks, in words an operator can act on', () => {
184
+ for (const step of verificationPlan(ok({ ...BASE, s3: S3 }))) {
185
+ assert.ok(step.title.length > 5, `step ${step.id} needs a title`);
186
+ assert.ok(step.detail.length > 10, `step ${step.id} needs a detail`);
187
+ }
188
+ });
189
+
190
+ // -------------------------------------------------------- operator duties
191
+
192
+ test('the run never claims to own the deployment forever', () => {
193
+ // The breaker trap: "done" implies ownership. This scaffolded and verified it
194
+ // once; rotation, backups, upgrades and the bill stay with the operator.
195
+ const duties = operatorDuties(ok(BASE));
196
+ const text = duties
197
+ .map((d) => `${d.title} ${d.detail}`)
198
+ .join(' ')
199
+ .toLowerCase();
200
+ assert.ok(duties.length >= 5);
201
+ assert.match(text, /rotate/);
202
+ assert.match(text, /restore drill/);
203
+ assert.match(text, /upgrade/);
204
+ assert.match(text, /bill/);
205
+ });
206
+
207
+ test('the duties card warns about `latest` by name', () => {
208
+ const pinned = operatorDuties(ok({ ...BASE, imageTag: 'v0.48.0' }));
209
+ assert.match(pinned.find((d) => /pin/i.test(d.title)).detail, /v0\.48\.0/);
210
+ const floating = operatorDuties(ok(BASE));
211
+ assert.match(floating.find((d) => /pin/i.test(d.title)).detail, /unplanned upgrade/);
212
+ });
213
+
214
+ test('object storage adds the never-expire duty; dev MinIO adds its own warning', () => {
215
+ const withS3 = operatorDuties(ok({ ...BASE, s3: S3 }))
216
+ .map((d) => d.title)
217
+ .join(' ');
218
+ assert.match(withS3, /Never expire the assets\/ prefix/);
219
+ const dev = operatorDuties(ok({ ...BASE, devMinio: true }))
220
+ .map((d) => d.title)
221
+ .join(' ');
222
+ assert.match(dev, /MinIO credentials are in this directory/);
223
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@1agh/maude",
3
- "version": "0.48.0",
3
+ "version": "0.49.0",
4
4
  "description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -51,13 +51,13 @@
51
51
  "test:e2e:desktop:acp-cold-start": "pnpm --filter @maude/desktop-e2e e2e:acp-cold-start"
52
52
  },
53
53
  "optionalDependencies": {
54
- "@1agh/maude-darwin-arm64": "0.48.0",
55
- "@1agh/maude-darwin-x64": "0.48.0",
56
- "@1agh/maude-linux-arm64": "0.48.0",
57
- "@1agh/maude-linux-arm64-musl": "0.48.0",
58
- "@1agh/maude-linux-x64": "0.48.0",
59
- "@1agh/maude-linux-x64-musl": "0.48.0",
60
- "@1agh/maude-win32-x64": "0.48.0"
54
+ "@1agh/maude-darwin-arm64": "0.49.0",
55
+ "@1agh/maude-darwin-x64": "0.49.0",
56
+ "@1agh/maude-linux-arm64": "0.49.0",
57
+ "@1agh/maude-linux-arm64-musl": "0.49.0",
58
+ "@1agh/maude-linux-x64": "0.49.0",
59
+ "@1agh/maude-linux-x64-musl": "0.49.0",
60
+ "@1agh/maude-win32-x64": "0.49.0"
61
61
  },
62
62
  "files": [
63
63
  "cli",