@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
@@ -32,7 +32,20 @@ function withDataDir(fn) {
32
32
  test('hub help prints subcommand summary on stdout', () => {
33
33
  const res = runCli(['hub', 'help']);
34
34
  assert.equal(res.status, 0, res.stderr);
35
- assert.match(res.stdout, /maude hub <serve\|token\|status\|deploy>/);
35
+ // Every verb must appear in the usage header — a subcommand that exists but
36
+ // isn't listed is one nobody finds.
37
+ assert.match(res.stdout, /maude hub <[a-z|-]+> \[options\]/);
38
+ for (const verb of [
39
+ 'serve',
40
+ 'token',
41
+ 'status',
42
+ 'deploy',
43
+ 'backup',
44
+ 'restore-drill',
45
+ 'asset-check',
46
+ ]) {
47
+ assert.ok(res.stdout.includes(verb), `usage must mention "${verb}"`);
48
+ }
36
49
  assert.match(res.stdout, /token generate --label NAME/);
37
50
  assert.match(res.stdout, /token rotate --label NAME/);
38
51
  assert.match(res.stdout, /deploy <fly\|docker>/);
@@ -0,0 +1,302 @@
1
+ // `maude cell` — the pure planning layer. Cloud Phase 5 Task 2.
2
+ //
3
+ // Naming, addressing, isolation boundaries, and the teardown sweep, as
4
+ // functions of their inputs. No Cloudflare API calls; `cli/commands/cell.mjs`
5
+ // makes those.
6
+ //
7
+ // Why this is worth separating (same argument as workspace-plan.mjs, and it
8
+ // matters more here): the failure modes of a multi-tenant provisioner are
9
+ // almost entirely NAMING and SCOPE mistakes — a tenant id that escapes its R2
10
+ // prefix, a hostname that collides with another tenant's, a destroy that
11
+ // misses one resource and leaves an orphan billing forever. All of those are
12
+ // decisions, and decisions can be tested exhaustively without a paid account.
13
+
14
+ /**
15
+ * A tenant id is not cosmetic. It becomes:
16
+ * • the R2 key prefix `tenants/<id>/`,
17
+ * • the subdomain `<id>.cloud.maude.sh`,
18
+ * • the Durable Object name,
19
+ * • the container instance name.
20
+ *
21
+ * So it has to satisfy the intersection of DNS labels, R2 keys, and Cloudflare
22
+ * resource names, and it must be impossible to construct one that reads as
23
+ * another tenant's namespace.
24
+ */
25
+ const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9-]{1,61}[a-z0-9])?$/;
26
+
27
+ /**
28
+ * Reserved for the platform itself. `api.cloud.maude.sh` belonging to a tenant
29
+ * who signed up early would be a phishing surface handed out for free.
30
+ */
31
+ const RESERVED_IDS = new Set([
32
+ 'api',
33
+ 'www',
34
+ 'admin',
35
+ 'app',
36
+ 'dashboard',
37
+ 'status',
38
+ 'docs',
39
+ 'help',
40
+ 'support',
41
+ 'billing',
42
+ 'account',
43
+ 'accounts',
44
+ 'login',
45
+ 'signin',
46
+ 'signup',
47
+ 'auth',
48
+ 'cdn',
49
+ 'assets',
50
+ 'static',
51
+ 'mail',
52
+ 'smtp',
53
+ 'ns',
54
+ 'ns1',
55
+ 'ns2',
56
+ 'internal',
57
+ 'system',
58
+ 'maude',
59
+ 'cloud',
60
+ 'test',
61
+ 'staging',
62
+ 'prod',
63
+ 'production',
64
+ ]);
65
+
66
+ export function validateTenantId(raw) {
67
+ const id = String(raw ?? '')
68
+ .trim()
69
+ .toLowerCase();
70
+ if (!id) return { ok: false, error: 'a project id is required' };
71
+ if (id.length > 63) return { ok: false, error: 'project id must be at most 63 characters' };
72
+ if (!TENANT_ID_RE.test(id)) {
73
+ return {
74
+ ok: false,
75
+ error:
76
+ 'project id must be lowercase letters, digits and inner hyphens ' +
77
+ '(it becomes a hostname, an R2 key prefix and a Durable Object name)',
78
+ };
79
+ }
80
+ if (RESERVED_IDS.has(id)) {
81
+ // Handing out `api.cloud.maude.sh` to whoever signs up first is a phishing
82
+ // surface given away for free.
83
+ return { ok: false, error: `"${id}" is reserved for the platform` };
84
+ }
85
+ if (id.startsWith('xn--')) {
86
+ return { ok: false, error: 'punycode-style ids are not allowed (homograph risk)' };
87
+ }
88
+ return { ok: true, id };
89
+ }
90
+
91
+ export const DEFAULT_ZONE = 'cloud.maude.sh';
92
+
93
+ /**
94
+ * Every name a cell occupies, derived from one id so they cannot drift apart.
95
+ * Drift is how a destroy misses a resource.
96
+ */
97
+ export function cellNames(id, { zone = DEFAULT_ZONE } = {}) {
98
+ const check = validateTenantId(id);
99
+ if (!check.ok) throw new Error(`invalid project id: ${check.error}`);
100
+ const tenant = check.id;
101
+ return {
102
+ tenant,
103
+ hostname: `${tenant}.${zone}`,
104
+ /** Trailing slash included: it is what makes prefix matching exact. */
105
+ r2Prefix: `tenants/${tenant}/`,
106
+ durableObject: `cell-${tenant}`,
107
+ container: `maude-cell-${tenant}`,
108
+ /** Per-cell scoped token name — never the account master key (DDR-164). */
109
+ r2TokenName: `maude-cell-${tenant}-r2`,
110
+ };
111
+ }
112
+
113
+ /**
114
+ * True when `key` belongs to `tenant`'s prefix.
115
+ *
116
+ * Exists as its own function because "starts with the prefix" is subtly wrong
117
+ * without the trailing slash: `tenants/acme` would match `tenants/acme-evil/…`.
118
+ * That single character is the entire isolation boundary between two
119
+ * customers' data.
120
+ */
121
+ export function keyBelongsToTenant(key, tenant) {
122
+ const names = cellNames(tenant);
123
+ return typeof key === 'string' && key.startsWith(names.r2Prefix);
124
+ }
125
+
126
+ /**
127
+ * The resources a cell owns, in the order `destroy` must remove them.
128
+ *
129
+ * Order is deliberate: stop routing FIRST, then compute, then credentials,
130
+ * then data. Deleting data while the hostname still resolves gives a live
131
+ * endpoint serving a half-deleted project; revoking the token before stopping
132
+ * the container gives a running cell erroring in a loop.
133
+ *
134
+ * `retainable` marks what a SUSPEND keeps. Suspension stops a cell; it does not
135
+ * delete state (DDR-193 §3) — the 30-day retention and export-before-teardown
136
+ * guarantee live here.
137
+ */
138
+ export function cellResources(id, { zone = DEFAULT_ZONE } = {}) {
139
+ const n = cellNames(id, { zone });
140
+ return [
141
+ {
142
+ kind: 'dns',
143
+ name: n.hostname,
144
+ why: 'stop routing first — otherwise a half-deleted project stays reachable',
145
+ retainable: false,
146
+ },
147
+ {
148
+ kind: 'worker-route',
149
+ name: `${n.hostname}/*`,
150
+ why: 'the ingress route that maps the hostname to this cell’s Durable Object',
151
+ retainable: false,
152
+ },
153
+ {
154
+ kind: 'container',
155
+ name: n.container,
156
+ why: 'the running cell',
157
+ retainable: false,
158
+ },
159
+ {
160
+ kind: 'durable-object',
161
+ name: n.durableObject,
162
+ why: 'lifecycle + hibernation state for this project',
163
+ retainable: false,
164
+ },
165
+ {
166
+ kind: 'r2-token',
167
+ name: n.r2TokenName,
168
+ why: 'the per-cell scoped credential; revoked after compute is gone, never before',
169
+ retainable: false,
170
+ },
171
+ {
172
+ kind: 'r2-prefix',
173
+ name: n.r2Prefix,
174
+ why: 'the tenant’s data — retained through suspension, deleted only after export',
175
+ retainable: true,
176
+ },
177
+ ];
178
+ }
179
+
180
+ /**
181
+ * What `destroy` must verify is gone. A destroy that leaves an orphan bills
182
+ * forever and is invisible until the invoice, so the sweep is part of the
183
+ * operation rather than a separate cleanup task somebody remembers to run.
184
+ */
185
+ export function destroySweep(id, { zone = DEFAULT_ZONE, purgeData = false } = {}) {
186
+ return cellResources(id, { zone }).filter((r) => purgeData || !r.retainable);
187
+ }
188
+
189
+ /**
190
+ * The tenant lifecycle from DDR-193 §3, as a machine.
191
+ *
192
+ * Encoded rather than implied because the ONE transition that must never exist
193
+ * is `* → purged` without passing through `exported`: there is no path from
194
+ * "stopped paying" to "your designs are gone" that skips "you were handed your
195
+ * files".
196
+ */
197
+ const TRANSITIONS = {
198
+ pending: ['active', 'purged'],
199
+ active: ['past_due', 'suspended'],
200
+ past_due: ['active', 'suspended'],
201
+ suspended: ['active', 'exported'],
202
+ exported: ['active', 'purged'],
203
+ purged: [],
204
+ };
205
+
206
+ export function canTransition(from, to) {
207
+ return (TRANSITIONS[from] ?? []).includes(to);
208
+ }
209
+
210
+ export function assertTransition(from, to) {
211
+ if (!TRANSITIONS[from]) throw new Error(`unknown state "${from}"`);
212
+ if (!TRANSITIONS[to]) throw new Error(`unknown state "${to}"`);
213
+ if (!canTransition(from, to)) {
214
+ const extra =
215
+ to === 'purged' && from !== 'exported'
216
+ ? ' — a tenant must be EXPORTED before purge (DDR-193 §3): there is no path from ' +
217
+ '"stopped paying" to "your designs are gone" that skips "you were handed your files"'
218
+ : '';
219
+ throw new Error(`illegal transition ${from} → ${to}${extra}`);
220
+ }
221
+ return to;
222
+ }
223
+
224
+ export function lifecycleStates() {
225
+ return Object.keys(TRANSITIONS);
226
+ }
227
+
228
+ /**
229
+ * The next state on the shortest LEGAL path from `from` to `to`, or null when
230
+ * no path exists.
231
+ *
232
+ * Needed because a desired state is often not reachable in one hop and that is
233
+ * perfectly ordinary rather than an error: a `pending` tenant whose payment is
234
+ * already failing did subscribe, so the truthful route is
235
+ * `pending → active → past_due`, and a `suspended` tenant who resubscribes with
236
+ * a failing card goes `suspended → active → past_due`.
237
+ *
238
+ * Walking the path beats jumping to the destination, because every hop stays
239
+ * inside the machine — including the one hop the machine exists to forbid.
240
+ * `exported` is the only door to `purged` no matter how the search runs.
241
+ */
242
+ export function stepToward(from, to) {
243
+ if (from === to) return null;
244
+ if (!TRANSITIONS[from] || !TRANSITIONS[to]) return null;
245
+ const queue = [[from, null]];
246
+ const seen = new Set([from]);
247
+ while (queue.length > 0) {
248
+ const [state, firstHop] = queue.shift();
249
+ for (const next of TRANSITIONS[state]) {
250
+ if (seen.has(next)) continue;
251
+ const hop = firstHop ?? next;
252
+ if (next === to) return hop;
253
+ seen.add(next);
254
+ queue.push([next, hop]);
255
+ }
256
+ }
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Render the container + ingress config for one cell.
262
+ *
263
+ * Kept as data (not a template string) so a test can assert a specific field
264
+ * without matching a whole file, and so the control plane in Phase 7 can build
265
+ * the same object without re-parsing TOML.
266
+ */
267
+ export function cellConfig(
268
+ id,
269
+ { zone = DEFAULT_ZONE, memoryMib = 2048, imageTag = 'latest' } = {}
270
+ ) {
271
+ const n = cellNames(id, { zone });
272
+ if (!Number.isInteger(memoryMib) || memoryMib < 512 || memoryMib > 4096) {
273
+ // Containers GA caps at 4 GiB; below 512 MiB the hub cannot hold a
274
+ // reasonable working set. Both bounds are refusals, not clamps — silently
275
+ // giving someone less memory than they asked for is a support ticket
276
+ // nobody can diagnose.
277
+ throw new Error('memoryMib must be an integer between 512 and 4096');
278
+ }
279
+ return {
280
+ name: n.container,
281
+ image: `ghcr.io/1agh/maude-cell:${imageTag}`,
282
+ instanceType: { memoryMib, vcpu: 0.5 },
283
+ // Scale to zero: an idle cell should cost approximately nothing under
284
+ // Active-CPU pricing, which is the whole economic premise (DDR-193 §1).
285
+ lifecycle: { scaleToZero: true, wakeOn: ['http', 'websocket'], idleTimeoutSeconds: 300 },
286
+ durableObject: n.durableObject,
287
+ route: `${n.hostname}/*`,
288
+ env: {
289
+ MAUDE_TENANT_ID: n.tenant,
290
+ HUB_PUBLIC_URL: `https://${n.hostname}`,
291
+ HUB_WORKSPACE_MODE: '1',
292
+ MAUDE_WORKSPACE_MODE: '1',
293
+ // Caddy is gone — TLS terminates at the Cloudflare edge, and the Worker
294
+ // is the only thing that can reach the container.
295
+ HUB_TRUSTED_PROXIES: '10.0.0.0/8,172.16.0.0/12,fd00::/8',
296
+ },
297
+ // Secrets are NAMED here, never valued: this object is logged, diffed and
298
+ // stored by the control plane.
299
+ secrets: ['HUB_SECRET', 'MAUDE_S3_ACCESS_KEY_ID', 'MAUDE_S3_SECRET_ACCESS_KEY'],
300
+ r2: { prefix: n.r2Prefix, tokenName: n.r2TokenName },
301
+ };
302
+ }
@@ -0,0 +1,225 @@
1
+ // Cloud Phase 5 Task 2 — cell naming, isolation, teardown and lifecycle.
2
+ //
3
+ // A multi-tenant provisioner fails at NAMING and SCOPE: an id that escapes its
4
+ // R2 prefix, a hostname handed to the wrong party, a destroy that leaves an
5
+ // orphan billing forever. Those are decisions, and decisions test exhaustively
6
+ // without a paid Cloudflare account — which is the point of this file existing
7
+ // before the account does.
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+
12
+ import {
13
+ assertTransition,
14
+ canTransition,
15
+ cellConfig,
16
+ cellNames,
17
+ cellResources,
18
+ destroySweep,
19
+ keyBelongsToTenant,
20
+ lifecycleStates,
21
+ validateTenantId,
22
+ } from './cell-plan.mjs';
23
+
24
+ // ------------------------------------------------------------------ naming
25
+
26
+ test('a well-formed id is accepted and lowercased', () => {
27
+ assert.deepEqual(validateTenantId(' Alligators '), { ok: true, id: 'alligators' });
28
+ assert.deepEqual(validateTenantId('acme-design-2'), { ok: true, id: 'acme-design-2' });
29
+ });
30
+
31
+ test('ids that would break a hostname, an R2 key or a DO name are refused', () => {
32
+ for (const bad of [
33
+ '',
34
+ ' ',
35
+ '-leading',
36
+ 'trailing-',
37
+ 'has_underscore',
38
+ 'has.dot',
39
+ 'has space',
40
+ 'UPPER-ONLY-OK-BUT/slash',
41
+ '../escape',
42
+ 'tenants/other',
43
+ 'a'.repeat(64),
44
+ 'emoji-🙂',
45
+ ]) {
46
+ assert.equal(validateTenantId(bad).ok, false, `${JSON.stringify(bad)} must be refused`);
47
+ }
48
+ });
49
+
50
+ test('platform names are reserved', () => {
51
+ // `api.cloud.maude.sh` belonging to whoever signs up first is a phishing
52
+ // surface given away for free.
53
+ for (const reserved of ['api', 'www', 'admin', 'billing', 'login', 'assets', 'maude']) {
54
+ const res = validateTenantId(reserved);
55
+ assert.equal(res.ok, false);
56
+ assert.match(res.error, /reserved/);
57
+ }
58
+ });
59
+
60
+ test('punycode-style ids are refused (homograph risk)', () => {
61
+ assert.equal(validateTenantId('xn--80ak6aa92e').ok, false);
62
+ });
63
+
64
+ test('every name a cell occupies derives from ONE id', () => {
65
+ // Drift between these is how a destroy misses a resource.
66
+ const n = cellNames('alligators');
67
+ assert.deepEqual(n, {
68
+ tenant: 'alligators',
69
+ hostname: 'alligators.cloud.maude.sh',
70
+ r2Prefix: 'tenants/alligators/',
71
+ durableObject: 'cell-alligators',
72
+ container: 'maude-cell-alligators',
73
+ r2TokenName: 'maude-cell-alligators-r2',
74
+ });
75
+ assert.throws(() => cellNames('../evil'), /invalid project id/);
76
+ });
77
+
78
+ // --------------------------------------------------------------- isolation
79
+
80
+ test('the trailing slash IS the isolation boundary between two customers', () => {
81
+ // Without it, `tenants/acme` matches `tenants/acme-evil/secret.png`. One
82
+ // character, and it is the entire boundary.
83
+ assert.equal(keyBelongsToTenant('tenants/acme/assets/deadbeef.png', 'acme'), true);
84
+ assert.equal(keyBelongsToTenant('tenants/acme/', 'acme'), true);
85
+ assert.equal(keyBelongsToTenant('tenants/acme-evil/assets/x.png', 'acme'), false);
86
+ assert.equal(keyBelongsToTenant('tenants/acmex/x.png', 'acme'), false);
87
+ assert.equal(keyBelongsToTenant('tenants/other/x.png', 'acme'), false);
88
+ assert.equal(keyBelongsToTenant('backups/hub.db.gz', 'acme'), false);
89
+ assert.equal(keyBelongsToTenant('', 'acme'), false);
90
+ assert.equal(keyBelongsToTenant(null, 'acme'), false);
91
+ });
92
+
93
+ test('two tenants can never share a prefix, hostname or DO name', () => {
94
+ const a = cellNames('acme');
95
+ const b = cellNames('acme-evil');
96
+ assert.notEqual(a.r2Prefix, b.r2Prefix);
97
+ assert.notEqual(a.hostname, b.hostname);
98
+ assert.notEqual(a.durableObject, b.durableObject);
99
+ assert.ok(!b.r2Prefix.startsWith(a.r2Prefix));
100
+ });
101
+
102
+ // ---------------------------------------------------------------- teardown
103
+
104
+ test('destroy removes routing first and data last', () => {
105
+ // Deleting data while the hostname resolves leaves a live endpoint serving a
106
+ // half-deleted project; revoking the credential before stopping the container
107
+ // leaves a cell erroring in a loop.
108
+ const order = cellResources('acme').map((r) => r.kind);
109
+ assert.deepEqual(order, [
110
+ 'dns',
111
+ 'worker-route',
112
+ 'container',
113
+ 'durable-object',
114
+ 'r2-token',
115
+ 'r2-prefix',
116
+ ]);
117
+ assert.ok(order.indexOf('dns') < order.indexOf('container'));
118
+ assert.ok(order.indexOf('container') < order.indexOf('r2-token'));
119
+ assert.ok(order.indexOf('r2-token') < order.indexOf('r2-prefix'));
120
+ });
121
+
122
+ test('every resource explains why it is in the list', () => {
123
+ for (const r of cellResources('acme')) {
124
+ assert.ok(r.why && r.why.length > 15, `${r.kind} needs a reason`);
125
+ assert.ok(r.name, `${r.kind} needs a name`);
126
+ }
127
+ });
128
+
129
+ test('a suspend keeps the DATA and drops everything else', () => {
130
+ // Suspension stops a cell; it does not delete state (DDR-193 §3).
131
+ const suspend = destroySweep('acme').map((r) => r.kind);
132
+ assert.ok(!suspend.includes('r2-prefix'), 'suspension must never delete the tenant’s data');
133
+ assert.ok(suspend.includes('container'));
134
+
135
+ const purge = destroySweep('acme', { purgeData: true }).map((r) => r.kind);
136
+ assert.ok(purge.includes('r2-prefix'), 'a full purge does remove it');
137
+ assert.equal(purge.length, cellResources('acme').length);
138
+ });
139
+
140
+ // --------------------------------------------------------------- lifecycle
141
+
142
+ test('the state machine matches DDR-193 §3', () => {
143
+ assert.deepEqual(lifecycleStates(), [
144
+ 'pending',
145
+ 'active',
146
+ 'past_due',
147
+ 'suspended',
148
+ 'exported',
149
+ 'purged',
150
+ ]);
151
+ assert.equal(canTransition('pending', 'active'), true);
152
+ assert.equal(canTransition('active', 'past_due'), true);
153
+ assert.equal(canTransition('past_due', 'active'), true, 'paying again must resurrect');
154
+ assert.equal(canTransition('past_due', 'suspended'), true);
155
+ assert.equal(canTransition('suspended', 'active'), true, 'suspension must be reversible');
156
+ assert.equal(canTransition('suspended', 'exported'), true);
157
+ assert.equal(canTransition('exported', 'purged'), true);
158
+ });
159
+
160
+ test('NOTHING reaches purged without passing through exported', () => {
161
+ // The one transition that must never exist: there is no path from "stopped
162
+ // paying" to "your designs are gone" that skips "you were handed your files".
163
+ for (const from of ['active', 'past_due', 'suspended']) {
164
+ assert.equal(canTransition(from, 'purged'), false, `${from} → purged must be illegal`);
165
+ assert.throws(() => assertTransition(from, 'purged'), /must be EXPORTED before purge/);
166
+ }
167
+ // `pending → purged` is allowed: a cell that never became active has no
168
+ // customer data to hand back.
169
+ assert.equal(canTransition('pending', 'purged'), true);
170
+ });
171
+
172
+ test('purged is terminal, and unknown states are refused', () => {
173
+ for (const to of lifecycleStates()) {
174
+ assert.equal(canTransition('purged', to), false);
175
+ }
176
+ assert.throws(() => assertTransition('nonsense', 'active'), /unknown state/);
177
+ assert.throws(() => assertTransition('active', 'deleted'), /unknown state/);
178
+ });
179
+
180
+ // ------------------------------------------------------------------ config
181
+
182
+ test('the rendered cell config carries the containment flags', () => {
183
+ const cfg = cellConfig('alligators');
184
+ assert.equal(cfg.env.MAUDE_WORKSPACE_MODE, '1');
185
+ assert.equal(cfg.env.HUB_WORKSPACE_MODE, '1');
186
+ assert.equal(cfg.env.MAUDE_TENANT_ID, 'alligators');
187
+ assert.equal(cfg.env.HUB_PUBLIC_URL, 'https://alligators.cloud.maude.sh');
188
+ assert.equal(cfg.route, 'alligators.cloud.maude.sh/*');
189
+ assert.equal(cfg.r2.prefix, 'tenants/alligators/');
190
+ });
191
+
192
+ test('secrets are NAMED, never valued', () => {
193
+ // This object is logged, diffed and stored by the control plane.
194
+ const cfg = cellConfig('acme');
195
+ assert.deepEqual(cfg.secrets, [
196
+ 'HUB_SECRET',
197
+ 'MAUDE_S3_ACCESS_KEY_ID',
198
+ 'MAUDE_S3_SECRET_ACCESS_KEY',
199
+ ]);
200
+ for (const value of Object.values(cfg.env)) {
201
+ assert.ok(!/secret|password|key/i.test(String(value)), 'no secret may appear in env values');
202
+ }
203
+ });
204
+
205
+ test('an idle cell scales to zero and wakes on connect', () => {
206
+ // The economic premise: an idle cell costs approximately nothing under
207
+ // Active-CPU pricing (DDR-193 §1).
208
+ const cfg = cellConfig('acme');
209
+ assert.equal(cfg.lifecycle.scaleToZero, true);
210
+ assert.deepEqual(cfg.lifecycle.wakeOn, ['http', 'websocket']);
211
+ assert.ok(cfg.lifecycle.idleTimeoutSeconds > 0);
212
+ });
213
+
214
+ test('memory outside the platform bounds is REFUSED, not clamped', () => {
215
+ // Silently giving someone less memory than they asked for is a support
216
+ // ticket nobody can diagnose.
217
+ assert.equal(cellConfig('acme', { memoryMib: 4096 }).instanceType.memoryMib, 4096);
218
+ for (const bad of [0, 256, 8192, 1024.5, '2048']) {
219
+ assert.throws(() => cellConfig('acme', { memoryMib: bad }), /between 512 and 4096/);
220
+ }
221
+ });
222
+
223
+ test('the image tag is pinnable', () => {
224
+ assert.match(cellConfig('acme', { imageTag: 'v0.48.0' }).image, /maude-cell:v0\.48\.0$/);
225
+ });
@@ -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,17 @@ 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
+ : []),
54
65
  // kgai knowledge-graph projection (repo-root, NOT under the design root) —
55
66
  // per-machine append-only store, rebuilds from the remote on `kg sync`
56
67
  // (feature-kgai-ecosystem-integration, DDR-115). kgai itself also ignores it.