@bongos/core 1.25.5 → 1.25.7

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 (46) hide show
  1. package/.bongos-core.json +69 -49
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +56 -2
  8. package/docs/api-reference.md +4 -3
  9. package/docs/copy-inventory.md +20 -19
  10. package/docs/copy-registry.json +32 -23
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +49 -52
  13. package/modules/hall-ui/public/shell-game.js +132 -5
  14. package/modules/hall-ui/public/style.css +59 -0
  15. package/modules/hall-ui/public/visor.css +11 -0
  16. package/modules/hall-ui/public/visor.js +14 -5
  17. package/modules/hall-ui/public/welcome-boot.js +27 -11
  18. package/modules/hall-ui/records/game-menu.md +22 -0
  19. package/modules/hall-ui/records/game-mode.md +2 -2
  20. package/modules/hall-ui/records/options-menu.md +1 -1
  21. package/modules/hall-ui/records/start-screen.md +12 -2
  22. package/modules/hall-ui/records/visor.md +4 -0
  23. package/modules/onboarding/genesis-home.js +35 -1
  24. package/modules/onboarding/routes/onboarding.js +7 -2
  25. package/modules/provisioning/catalog-bridge.js +3 -0
  26. package/modules/provisioning/paid-shape-gate.js +3 -1
  27. package/modules/provisioning/provisioning.js +3 -3
  28. package/modules/provisioning/routes/provisioning.js +7 -4
  29. package/modules/provisioning/test-project.js +65 -0
  30. package/modules/ui-design/public/brand.html +1 -1
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +25 -0
  34. package/scripts/bongos/government-walk.js +2 -2
  35. package/src/branding.js +7 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/currency_label.mjs +3 -1
  38. package/tests/hall_game_menu.mjs +195 -0
  39. package/tests/hall_options_menu.mjs +4 -1
  40. package/tests/hall_start_screen.mjs +62 -8
  41. package/tests/hall_visor.mjs +40 -5
  42. package/tests/provision_settings_apply.mjs +2 -1
  43. package/tests/provisioning_paid_shape_gate.mjs +16 -0
  44. package/tests/provisioning_settings_env.mjs +2 -2
  45. package/tests/test_project.mjs +122 -0
  46. package/tests/wizard_demo.mjs +25 -4
@@ -105,8 +105,8 @@ test('no key ever means two things: every character over every real scene, and O
105
105
  assert.notEqual(r.options.key, 'esc', `${c}: Options is not on Escape; Escape is Close`);
106
106
  assert.ok(r.options.key, `${c}: Options has a key of its own`);
107
107
  }
108
- assert.equal(V.resolveKeys('artist').options.key, 'o', 'O is Options where nothing else holds it');
109
- assert.equal(V.resolveKeys('patron').options.key, 'p', "the Patron's O is the Board room, so Options moves to its next letter");
108
+ assert.equal(V.resolveKeys('artist').options.key, 'm', 'M is the game menu where nothing else holds it');
109
+ assert.equal(V.resolveKeys('patron').options.key, 'm', 'no character holds M, so the game menu keeps it for all three');
110
110
  });
111
111
 
112
112
  test('a clash moves the later claimant to a free letter of its own name, and its key follows', () => {
@@ -205,6 +205,25 @@ test('source: the minimal visor spends the Helm tokens and types in no colour, s
205
205
  assert.doesNotMatch(glass, /background|box-shadow|backdrop-filter|filter:/, 'the glass itself paints nothing: no edge band, no vignette, no veil');
206
206
  assert.doesNotMatch(css, /--night-glass|linear-gradient|radial-gradient\((?!circle, currentColor)|--type-halo/, 'no band of the night anywhere on the glass, and no dark glow round its type');
207
207
  assert.match(css, /\.visor__place, \.visor__who, \.visor__hint \{ background: var\(--night-plate\); clip-path: var\(--cut-path-sm\); \}/, 'the place, each hint and your credits stand on a small cut plate of the night');
208
+ // smoked glass (task 1005252): where the browser can blur, each plate is a pane that softens the scene
209
+ // behind it, from the Helm tokens; asking for less transparency puts the night plate back
210
+ const PANES = '.visor__place, .visor__who, .visor__hint, .visor__progress-words {';
211
+ const block = (head) => { const at = css.indexOf(head); assert.ok(at >= 0, head); const open = css.indexOf(PANES, at); assert.ok(open > at, head + ' holds the four plates'); return css.slice(open, css.indexOf('}', open)); };
212
+ const smoke = block('@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {');
213
+ assert.ok(smoke.includes('background: var(--smoke-sheen), var(--smoke);'), 'a light smoke with a sheen, from tokens');
214
+ assert.ok(smoke.includes('box-shadow: var(--smoke-edge);'), 'a lit top edge makes it a pane');
215
+ assert.ok(smoke.includes(' backdrop-filter: var(--smoke-blur);'), 'it blurs the scene behind it');
216
+ const STYLE = fs.readFileSync(new URL('../modules/hall-ui/public/style.css', import.meta.url), 'utf8');
217
+ const plainAt = STYLE.indexOf('@media (prefers-reduced-transparency: reduce) {');
218
+ const plain = STYLE.slice(plainAt, STYLE.indexOf('}', plainAt));
219
+ assert.ok(plainAt >= 0 && plain.includes('html .visor__place, html .visor__who, html .visor__hint, html .visor__progress-words {'), 'less transparency is answered in style.css, lifted over visor.css');
220
+ assert.ok(plain.includes('background: var(--night-plate);') && plain.includes(' backdrop-filter: none;'), 'less transparency: the night plate again');
221
+ const smokeTokens = STYLE.match(/--smoke[a-z-]*:[^;]*;/g) || [];
222
+ assert.equal(smokeTokens.length, 4, 'four smoke tokens: the tint, the blur, the sheen, the edge');
223
+ assert.doesNotMatch(smokeTokens.join(' '), /rgba?\(|#[0-9a-f]{3,8}\b/i, 'every smoke colour derives from a Helm token, no literal');
224
+ assert.ok(STYLE.includes('html[data-game="on"] .visor__who, html[data-game="on"] .visor__hint { background: var(--night-plate); box-shadow: none; -webkit-backdrop-filter: none; backdrop-filter: none; }'), 'a phone keeps the night plate: no live blur on the weakest GPU');
225
+ assert.ok(STYLE.indexOf('backdrop-filter: none; }') > STYLE.indexOf('tasks 1005148 and 1005195, the visor'), 'and that rule sits in the shared phone block');
226
+ assert.ok(css.indexOf('@supports ((backdrop-filter') > css.indexOf('.visor__progress-words {'), 'the smoke comes after every plate rule, so it wins the cascade');
208
227
  assert.match(css, /\.visor__line--under \{ stroke: var\(--bg\); stroke-opacity: 1/, 'every line is drawn over a solid stroke of the night, so it reads on cloud and on night');
209
228
  assert.match(css, /\.visor__key \{[^}]*background: var\(--bg\)/, 'a key sits on a chip of the night');
210
229
  assert.doesNotMatch(css, /data-hour|data-time|data-sky/, 'no per-hour style');
@@ -365,12 +384,26 @@ test('a lead scene action is the first key hint, and its click and its key run i
365
384
  await ctx.close();
366
385
  });
367
386
 
387
+ // task 1005251: the options window keeps its aria-modal box after it closes. Only a dialog that
388
+ // is SHOWN holds the visor's keys; before this, one visit to Options left every key dead.
389
+ test('a closed dialog left in the page does not hold the keys; an open one does', { skip }, async () => {
390
+ const { ctx, page } = await open();
391
+ await page.evaluate(() => { const d = document.createElement('div'); d.id = 'gone'; d.setAttribute('aria-modal', 'true'); d.hidden = true; document.body.appendChild(d); });
392
+ await page.keyboard.press('a');
393
+ assert.equal(await page.evaluate(() => OTBVisor.isOpen()), true, 'a hidden modal box: A still opens the sheet');
394
+ await page.keyboard.press('Escape');
395
+ await page.evaluate(() => { document.getElementById('gone').hidden = false; document.getElementById('gone').textContent = 'up'; });
396
+ await page.keyboard.press('a');
397
+ assert.equal(await page.evaluate(() => OTBVisor.isOpen()), false, 'a modal that is up keeps the keyboard');
398
+ await ctx.close();
399
+ });
400
+
368
401
  test('the sheet lists the character\'s actions, then this place, and runs a scene action', { skip }, async () => {
369
402
  const { ctx, page } = await open();
370
403
  await page.click('[data-visor-sheet]');
371
404
  const rows = await page.$$eval('.visor__sheet .visor__row', (r) => r.map((x) => [...x.children].map((c) => c.textContent).join(' ')));
372
405
  // task 1005200: Claim a task and Blockers open classic pages, so they are off the glass.
373
- assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options O']);
406
+ assert.deepEqual(rows, ['Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Game menu M']);
374
407
  assert.equal(await page.$eval('.visor__sheet [data-visor-close]', (b) => b.textContent), 'Esc', 'Escape is the Close of the sheet, and only that');
375
408
  await page.click('[data-visor-scene="0"]');
376
409
  assert.deepEqual(await page.evaluate(() => ({ centred: window.centred, open: OTBVisor.isOpen() })), { centred: true, open: false });
@@ -481,7 +514,7 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
481
514
  return {
482
515
  mainBg: cs('.visor__main').backgroundColor, accent: probe('var(--accent)'), mainClip: cs('.visor__main').clipPath,
483
516
  mainCase: cs('.visor__main').textTransform, hintBg: cs('.visor__hint').backgroundColor, hintBorder: cs('.visor__hint').borderTopWidth,
484
- plate: probe('var(--night-plate)'), placeBg: cs('.visor__place').backgroundColor, whoBg: cs('.visor__who').backgroundColor, hintClip: cs('.visor__hint').clipPath,
517
+ plate: probe('var(--smoke)'), blur: cs('.visor__place').backdropFilter, edge: cs('.visor__hint').boxShadow, placeBg: cs('.visor__place').backgroundColor, whoBg: cs('.visor__who').backgroundColor, hintClip: cs('.visor__hint').clipPath,
485
518
  glassBg: cs('#visor').backgroundImage + ' ' + cs('#visor').backgroundColor,
486
519
  hintColor: cs('.visor__hint').color, ice: probe('var(--ink-soft)'), num: cs('.visor__num').color, unit: cs('.visor__unit').color, white: probe('var(--ink)'),
487
520
  placeShadow: cs('.visor__place').textShadow, radius: cs('.visor__sprite').borderTopLeftRadius, spriteClip: cs('.visor__sprite').clipPath,
@@ -492,7 +525,9 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
492
525
  assert.match(g.mainClip, /^polygon\(/, 'cut corner');
493
526
  assert.equal(g.mainCase, 'uppercase', 'the name voice');
494
527
  assert.equal(g.glassBg, 'none rgba(0, 0, 0, 0)', 'the glass darkens nothing: the scene shows to every edge');
495
- assert.equal(g.hintBg, g.plate, 'a hint stands on a plate of the night');
528
+ assert.equal(g.hintBg, g.plate, 'a hint stands on a pane of smoked glass (task 1005252)');
529
+ assert.match(g.blur, /blur\(14px\)/, 'the pane blurs the scene behind it');
530
+ assert.match(g.edge, /inset/, 'and has a lit edge');
496
531
  assert.equal(g.placeBg, g.plate, 'so does the place');
497
532
  assert.equal(g.whoBg, g.plate, 'and your credits');
498
533
  assert.match(g.hintClip, /^polygon\(/, 'the plate is cut');
@@ -80,6 +80,7 @@ ${NEUTRAL_PREFIX}_FOUNDING_GENESIS=
80
80
  ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
81
81
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
82
82
  ${NEUTRAL_PREFIX}_SHIP_MODE=
83
+ ${NEUTRAL_PREFIX}_TEST_PROJECT=
83
84
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
84
85
  `;
85
86
 
@@ -151,7 +152,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
151
152
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
152
153
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
153
154
  // the wizard's ship mode (task 1004629), empty when none was picked
154
- [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
155
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '',
155
156
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
156
157
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
157
158
  });
@@ -243,6 +243,22 @@ await ta('cloud-host: ADMITTED past the limit for a builder holding provisioning
243
243
  assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
244
244
  });
245
245
 
246
+ await ta('cloud-host: a TEST project holds no free place, so it does not make the next one a second (task 1005036)', async () => {
247
+ // Only an archon makes a test project, and it is for trying features: counting it would
248
+ // charge a builder's one free place for a project nobody meant to keep.
249
+ held = [];
250
+ owned = [{ ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
251
+ lastCreateArgs = null;
252
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
253
+ assert.equal(res.status, 201, 'a test project alone leaves the free place free: ' + res.raw);
254
+ held = [];
255
+ owned = [LIVE_HOSTED, { ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
256
+ lastCreateArgs = null;
257
+ const second = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
258
+ owned = [];
259
+ assert.equal(second.status, 403, 'a real project plus a test one still counts the real one');
260
+ });
261
+
246
262
  // ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
247
263
 
248
264
  await ta('control-plane: refused for an unprivileged builder, and the refusal is not a permission', async () => {
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
128
128
  // route never stamped, which is every row made before it shipped (spec D8).
129
129
  CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
130
130
  // The wizard's ship mode (task 1004629): empty when none was picked.
131
- CLOUDBONGOS_SHIP_MODE: '',
131
+ CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '',
132
132
  });
133
133
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
134
134
  // of the comparison is that the standup lines carry exactly the same key set.
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
137
137
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
138
138
  X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
139
139
  X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
140
- X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
140
+ X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '',
141
141
  }, 'defaults, explicitly');
142
142
  });
143
143
 
@@ -0,0 +1,122 @@
1
+ // tests/test_project.mjs — A TEST PROJECT: one made to try features that are not live yet
2
+ // (task 1005036, idea 1001299; owner, 2026-11-01).
3
+ //
4
+ // Done-when: only an archon can ask for one; it is stamped once on the row just made; it is
5
+ // kept off the public map; it reaches the instance as project.test; and the not-yet-live
6
+ // genesis switch opens on a test project and nowhere else. The free-place count is pinned in
7
+ // tests/provisioning_paid_shape_gate.mjs and the create route in tests/wizard_demo.mjs.
8
+ //
9
+ // Run: node --test tests/test_project.mjs
10
+
11
+ import { strict as assert } from 'node:assert';
12
+ import { test } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import { execFileSync } from 'node:child_process';
15
+ import path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const tp = require('../modules/provisioning/test-project.js');
21
+ const provisioning = require('../modules/provisioning/provisioning.js');
22
+ const bridge = require('../modules/provisioning/catalog-bridge.js');
23
+ const gh = require('../modules/onboarding/genesis-home.js');
24
+ const fm = require('../modules/onboarding/founding-mode.js');
25
+
26
+ const ARCHON = { id: 1, rank: 'archon' };
27
+ const METIC = { id: 2, rank: 'metic' };
28
+ const ROW = (settings) => ({ id: 41, slug: 'sandbox', domain: 'sandbox.example.test', status: 'active', hosting_shape: 'cloud-host', settings });
29
+
30
+ test('only an archon can ask for a test project, and genesis means nothing without one', () => {
31
+ assert.deepEqual(tp.parseTestAsk({ test: true }, ARCHON), { ok: true, test: true, genesis: false });
32
+ assert.deepEqual(tp.parseTestAsk({ test: true, genesis: true }, ARCHON), { ok: true, test: true, genesis: true });
33
+ assert.deepEqual(tp.parseTestAsk({}, METIC), { ok: true, test: false, genesis: false }, 'a request that asks for nothing is fine for anyone');
34
+ const refused = tp.parseTestAsk({ test: true }, METIC);
35
+ assert.equal(refused.ok, false);
36
+ assert.equal(refused.status, 403);
37
+ assert.equal(refused.code, 'test_project_forbidden');
38
+ assert.equal(tp.parseTestAsk({ test: true }, undefined).ok, false, 'no builder, no test project');
39
+ assert.equal(tp.parseTestAsk({ test: 'yes' }, METIC).ok, true, 'only a real boolean true asks');
40
+ const alone = tp.parseTestAsk({ genesis: true }, ARCHON);
41
+ assert.equal(alone.ok, false);
42
+ assert.equal(alone.code, 'genesis_needs_test');
43
+ assert.equal(alone.status, 400);
44
+ });
45
+
46
+ test('the mark: stamped on the row it is given, once; read only when it says on', async () => {
47
+ const seen = [];
48
+ await tp.stampTestProject({ query: async (sql, params) => { seen.push({ sql, params }); return { rows: [] }; } }, 41);
49
+ assert.equal(seen.length, 1);
50
+ assert.match(seen[0].sql, /WHERE id = \$1 AND NOT \(COALESCE\(settings, '\{\}'::jsonb\) \? \$2::text\)/, 'write-once');
51
+ assert.deepEqual(seen[0].params, [41, 'test_project']);
52
+ assert.equal(tp.isTestProject(ROW({ test_project: 'on' })), true);
53
+ for (const bad of [undefined, null, {}, { test_project: 'yes' }, { test_project: true }, 'on']) {
54
+ assert.equal(tp.isTestProject(ROW(bad)), false, JSON.stringify(bad));
55
+ }
56
+ assert.equal(tp.isTestProject(null), false);
57
+ });
58
+
59
+ test('the owner view says it, and it reaches web.env as TEST_PROJECT, empty on every other project', () => {
60
+ assert.equal(provisioning.publicInstance(ROW({ test_project: 'on' })).is_test, true);
61
+ assert.equal(provisioning.publicInstance(ROW({})).is_test, false);
62
+ assert.equal(provisioning.settingsEnvVars(ROW({ test_project: 'on' }), 'X').X_TEST_PROJECT, 'on');
63
+ assert.equal(provisioning.settingsEnvVars(ROW({}), 'X').X_TEST_PROJECT, '', 'a line that came and went would leave a stale one behind');
64
+ assert.equal(provisioning.settingsEnvVars(ROW({ test_project: 'maybe' }), 'X').X_TEST_PROJECT, '');
65
+ });
66
+
67
+ test('a test project is never on the public map; the same row without the mark is', () => {
68
+ assert.equal(bridge.catalogEntryForInstance(ROW({ test_project: 'on' }), { ownerGithubId: 99 }), null);
69
+ const plain = bridge.catalogEntryForInstance(ROW({}), { ownerGithubId: 99 });
70
+ assert.ok(plain && plain.clientId === 'sandbox', 'an ordinary project still projects');
71
+ });
72
+
73
+ test('the instance reads TEST_PROJECT into project.test, and publishes it as a boolean beside project, not in it', () => {
74
+ const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
75
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({p:r.project&&r.project.test,c:b.clientBranding(r).testProject}))"],
76
+ { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
77
+ assert.deepEqual(read({}), { c: false }, 'no env, not a test project');
78
+ assert.deepEqual(read({ CLOUDBONGOS_TEST_PROJECT: 'on' }), { p: 'on', c: true });
79
+ assert.deepEqual(read({ CLOUDBONGOS_TEST_PROJECT: '' }), { c: false }, 'the empty line every other project carries');
80
+ });
81
+
82
+ // ── the genesis switch, on the instance ────────────────────────────────────────
83
+
84
+ function world({ test = true, settings = {} } = {}) {
85
+ const store = new Map(Object.entries(settings));
86
+ const deps = {
87
+ branding: { project: { test: test ? 'on' : '' } },
88
+ getSetting: async (k) => (store.has(k) ? { key: k, value: store.get(k) } : null),
89
+ setSetting: async (k, v) => { store.set(k, String(v)); return { key: k, value: String(v) }; },
90
+ lifecycle: { seedGenesis: async () => ({ seeded: 0 }), genesisTasks: async () => ({}) },
91
+ government: null,
92
+ now: new Date('2026-11-02T09:00:00Z'),
93
+ };
94
+ return { store, deps };
95
+ }
96
+
97
+ test('genesis cannot be switched on from a project that is not a test project', async () => {
98
+ const w = world({ test: false });
99
+ assert.deepEqual(await gh.setEnabled(true, 1, w.deps), { ok: false, reason: 'not_a_test_project' });
100
+ assert.deepEqual(await gh.ownerStart(1, w.deps), { ok: false, reason: 'not_a_test_project' });
101
+ assert.equal(w.store.size, 0, 'nothing is written');
102
+ });
103
+
104
+ test('on a test project the switch starts genesis, pauses it, and brings it back', async () => {
105
+ const w = world();
106
+ const on = await gh.setEnabled(true, 1, w.deps);
107
+ assert.equal(on.ok, true);
108
+ assert.equal(on.mode.active, true);
109
+ assert.equal(w.store.get(fm.KEYS.ENABLED), 'true');
110
+ const off = await gh.setEnabled(false, 1, w.deps);
111
+ assert.equal(off.ok, true);
112
+ assert.equal(off.mode, null, 'paused: the hall opens full');
113
+ assert.equal(w.store.get(fm.KEYS.ACTIVE), 'true', 'the founding record is kept');
114
+ const again = await gh.setEnabled(true, 1, w.deps);
115
+ assert.equal(again.mode.active, true, 'it comes back where it was');
116
+ assert.equal((await gh.ownerStart(1, world().deps)).ok, true, 'the owner\'s own start works on a test project');
117
+ });
118
+
119
+ test('a genesis that came alive cannot be switched back on', async () => {
120
+ const w = world({ settings: { [fm.KEYS.STARTED_AT]: '2026-11-01T00:00:00Z', [fm.KEYS.ENDED_AT]: '2026-11-01T05:00:00Z' } });
121
+ assert.deepEqual(await gh.setEnabled(true, 1, w.deps), { ok: false, reason: 'already_alive' });
122
+ });
@@ -117,6 +117,9 @@ provisioning.getInstanceByDomain = async () => null;
117
117
  const order = [];
118
118
  const birth = require('../modules/provisioning/birth.js');
119
119
  birth.stampBirth = async (_db, id) => { order.push(`birth:${id}`); };
120
+ const testProject = require('../modules/provisioning/test-project.js');
121
+ testProject.stampTestProject = async (_db, id) => { order.push(`test:${id}`); };
122
+ let rank = 'metic'; // who is asking: a test project is an archon's alone (task 1005036)
120
123
  provisioning.createInstance = async (_db, args) => {
121
124
  created = args;
122
125
  const fresh = args.slug !== 'old-one';
@@ -126,7 +129,7 @@ provisioning.enqueueIntent = async (_db, _id, action) => { order.push(`enqueue:$
126
129
  provisioning.getOpenIntent = async () => null;
127
130
  provisioning.recordEvent = async () => {};
128
131
  demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; order.push(`demo:${id}`); return null; };
129
- api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
132
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner', rank }; next(); };
130
133
  let stored = null;
131
134
  provisioning.getInstanceById = async (_db, id) => (id === 41 ? { id: 41, owner_builder_id: 7, demo: stored } : null);
132
135
  api.pool.query = async (sql, params) => { if (/UPDATE provisioning_instances SET demo/.test(sql)) stored = JSON.parse(params[1]); return { rows: [] }; };
@@ -173,8 +176,26 @@ test('a new project is stamped born, once, before its provision intent; an exist
173
176
  assert.equal((await post('/provisioning/instances', { slug: 'born-real', hosting_shape: 'byo-host' })).status, 201);
174
177
  assert.deepEqual(order, ['enqueue:provision'], 'a real project opens as the full hall: genesis is off by default (owner, 2026-11-01)');
175
178
  order.length = 0;
176
- assert.equal((await post('/provisioning/instances', { slug: 'born-test', hosting_shape: 'byo-host', genesis: true })).status, 201);
177
- assert.deepEqual(order, ['birth:41', 'enqueue:provision'], 'the genesis toggle still births a test project, with no demo box');
179
+ // A test project (task 1005036): an archon's alone, marked once on the row, and the only way to be born with genesis.
180
+ const asked = await post('/provisioning/instances', { slug: 'sneaky', hosting_shape: 'byo-host', test: true });
181
+ assert.equal(asked.status, 403, 'a non-archon cannot make one');
182
+ assert.equal(asked.body.error.code, 'test_project_forbidden');
183
+ assert.deepEqual(order, [], 'and nothing is made, marked or queued');
184
+ const bare = await post('/provisioning/instances', { slug: 'bare-genesis', hosting_shape: 'byo-host', genesis: true });
185
+ assert.equal(bare.status, 400, 'genesis alone is no longer a way in');
186
+ assert.equal(bare.body.error.code, 'genesis_needs_test');
187
+ assert.deepEqual(order, []);
188
+ rank = 'archon';
189
+ try {
190
+ assert.equal((await post('/provisioning/instances', { slug: 'sandbox-a', hosting_shape: 'byo-host', test: true })).status, 201);
191
+ assert.deepEqual(order, ['test:41', 'enqueue:provision'], 'a test project is marked, not born into genesis');
192
+ order.length = 0;
193
+ assert.equal((await post('/provisioning/instances', { slug: 'sandbox-g', hosting_shape: 'byo-host', test: true, genesis: true })).status, 201);
194
+ assert.deepEqual(order, ['birth:41', 'test:41', 'enqueue:provision'], 'a test project that asks for genesis is born into it, marked, before the runner can read it');
195
+ order.length = 0;
196
+ assert.equal((await post('/provisioning/instances', { slug: 'archon-real', hosting_shape: 'byo-host' })).status, 201);
197
+ assert.deepEqual(order, ['enqueue:provision'], 'an ordinary project made by an archon is still an ordinary project');
198
+ } finally { rank = 'metic'; }
178
199
  order.length = 0;
179
200
  const again = await post('/provisioning/instances', { slug: 'old-one', hosting_shape: 'byo-host' });
180
201
  assert.ok(again.status < 300, JSON.stringify(again.body));
@@ -182,7 +203,7 @@ test('a new project is stamped born, once, before its provision intent; an exist
182
203
  const keep = birth.stampBirth;
183
204
  birth.stampBirth = async () => { throw new Error('db hiccup'); };
184
205
  try {
185
- assert.equal((await post('/provisioning/instances', { slug: 'unlucky', hosting_shape: 'byo-host', genesis: true })).status, 201,
206
+ assert.equal((await post('/provisioning/instances', { slug: 'unlucky', hosting_shape: 'byo-host', demo: { people: 2, hours_each: 3 } })).status, 201,
186
207
  'a missed marker costs the project its genesis, never its creation');
187
208
  } finally { birth.stampBirth = keep; }
188
209
  });