@bongos/core 1.20.61 → 1.20.62

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 (38) hide show
  1. package/.bongos-core.json +80 -35
  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 +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md +2 -0
  8. package/docs/api/openapi.json +96 -3
  9. package/docs/api-reference.md +4 -3
  10. package/docs/copy-inventory.md +372 -324
  11. package/docs/copy-registry.json +881 -417
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +4 -1
  14. package/docs/page-readings.json +544 -481
  15. package/docs/recipes/upgrading-the-core.md +30 -1
  16. package/modules/provisioning/demo.js +176 -0
  17. package/modules/provisioning/migrations/provisioning_035_demo.sql +26 -0
  18. package/modules/provisioning/module.json +5 -3
  19. package/modules/provisioning/pollers/demo-archive.js +61 -0
  20. package/modules/provisioning/provisioning.js +2 -2
  21. package/modules/provisioning/routes/demo.js +54 -0
  22. package/modules/provisioning/routes/provisioning.js +7 -4
  23. package/modules/public-landing/public/projects-demo.states.json +53 -0
  24. package/modules/public-landing/public/projects.html +300 -23
  25. package/modules/public-landing/public/projects.probes.json +3 -3
  26. package/modules/public-landing/public/projects.states.json +2 -1
  27. package/modules/ui-design/kit/fixtures/provisioning-instances-demo.json +114 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +14 -0
  31. package/scripts/gds/update-channel.js +11 -2
  32. package/scripts/gds/update-sweep.js +717 -0
  33. package/src/module-api.js +1 -1
  34. package/tests/update_channel_db.mjs +13 -6
  35. package/tests/update_subscription_engine.mjs +5 -5
  36. package/tests/update_sweep_home.mjs +100 -0
  37. package/tests/wizard_demo.mjs +264 -0
  38. package/tests/wizard_front_door.mjs +16 -20
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.61'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.62'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -46,8 +46,8 @@ test('DB pinned: the sweep skips it even when the roster says minor', () => {
46
46
  const [inst] = uc.applyDbChannels([entry({ channel: 'minor' })], dbOf({ cloudbongos: 'pinned' }));
47
47
  assert.equal(inst.channel, 'pinned');
48
48
  assert.equal(targetFor(inst), null);
49
- // subscribe.js skips on exactly this value, and decides it AFTER the DB rule is applied.
50
- const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
49
+ // update-sweep.js skips on exactly this value, and decides it AFTER the DB rule is applied.
50
+ const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
51
51
  const applied = src.indexOf('channel.applyDbChannels(');
52
52
  const skip = src.indexOf("inst.channel === 'pinned'");
53
53
  assert.ok(applied > 0 && skip > applied, 'the pinned skip must read the channel after the /deploy rule is applied');
@@ -69,10 +69,17 @@ test('readDbChannels: no database named → not read, with the reason, and psql
69
69
  let spawned = false;
70
70
  const r = uc.readDbChannels({ env: {}, run: () => { spawned = true; } });
71
71
  assert.equal(r.ok, false);
72
- assert.match(r.reason, /no DATABASE_URL or PGDATABASE/);
72
+ assert.match(r.reason, /no UPDATE_CHANNEL_DB \(or DATABASE_URL \/ PGDATABASE\)/);
73
73
  assert.equal(spawned, false);
74
74
  });
75
75
 
76
+ test('readDbChannels: UPDATE_CHANNEL_DB wins, so a unit need not set PGDATABASE (which leaks into tenant upgrades)', () => {
77
+ let target;
78
+ uc.readDbChannels({ env: { UPDATE_CHANNEL_DB: 'cloudbongos', DATABASE_URL: 'postgres://other', PGDATABASE: 'tenant' }, run: (_c, a) => { target = a[1]; return { status: 0, stdout: '' }; } });
79
+ assert.equal(target, 'cloudbongos');
80
+ assert.equal(uc.CHANNEL_DB_ENV, 'UPDATE_CHANNEL_DB');
81
+ });
82
+
76
83
  test('readDbChannels: parses psql -At tab output into slug → channel, from the sweep env only', () => {
77
84
  let args;
78
85
  const run = (cmd, a) => { args = [cmd, ...a]; return { status: 0, stdout: 'cloudbongos\tminor\ncharter\tpatch\n\n', stderr: '' }; };
@@ -112,12 +119,12 @@ test('channelLine: the sweep log names the channel and where it came from', () =
112
119
  [entry(), entry({ slug: 'charter', channel: 'patch' })], dbOf({ cloudbongos: 'minor' }));
113
120
  assert.equal(uc.channelLine(fromDeploy), ' • cloudbongos: channel minor, set on /deploy (roster says patch, ignored)');
114
121
  assert.equal(uc.channelLine(fromRoster), ' • charter: channel patch, from the roster — no project row for this slug');
115
- const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
122
+ const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
116
123
  assert.match(src, /console\.log\(channel\.channelLine\(inst\)\)/);
117
124
  });
118
125
 
119
- test('subscribe.js reads the DB from its own env, never an entry env (which names that instance\'s DB)', () => {
120
- const src = readFileSync(path.join(ROOT, '.claude/scheduled-tasks/core-update-subscription/subscribe.js'), 'utf8');
126
+ test('update-sweep.js reads the DB from its own env, never an entry env (which names that instance\'s DB)', () => {
127
+ const src = readFileSync(path.join(ROOT, 'scripts/gds/update-sweep.js'), 'utf8');
121
128
  assert.match(src, /channel\.readDbChannels\(\{ env: process\.env \}\)/);
122
129
  // A core too old to carry the reader must keep the roster rather than crash the sweep.
123
130
  assert.match(src, /typeof channel\.readDbChannels === 'function'/);
@@ -334,7 +334,7 @@ test('the heal branch is reachable only when the dirty files ARE the pin', () =>
334
334
  // ---------------------------------------------------------------------------
335
335
 
336
336
  test('every commitPin call site in the lane decides about push', () => {
337
- const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
337
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
338
338
  // Every CALL, excluding the definition (which is `function commitPin({ ... push = true })` —
339
339
  // its default is what these sites must not silently inherit).
340
340
  const calls = [];
@@ -358,7 +358,7 @@ test('every commitPin call site in the lane decides about push', () => {
358
358
  });
359
359
 
360
360
  test('the direct-upgrade fallback asks pinFlagsFor rather than deciding inline', () => {
361
- const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
361
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
362
362
  // The fourth version of this assertion. It used to pin the inline `if` verbatim, which froze
363
363
  // the bug of task 1004122 in place: the line was correct about NOT pushing a co-tenant pin and
364
364
  // wrong about how, and a test that quotes an expression can only ever confirm it is still there.
@@ -610,7 +610,7 @@ test('preflightFlagsFor has the precedent it claims — go-live.js really does s
610
610
  });
611
611
 
612
612
  test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding inline', () => {
613
- const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
613
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
614
614
  assert.match(src, /upArgs\.push\(\.\.\.preflightFlagsFor\(pinMode\)\)/,
615
615
  'the clean-tree policy is a pin-mode decision and must go through the named function');
616
616
  });
@@ -618,7 +618,7 @@ test('the direct-upgrade fallback asks preflightFlagsFor rather than deciding in
618
618
  test('every commitPin call site in the lane also decides what the upgrade generated', () => {
619
619
  // The sibling of the push assertion in section 8, and for the same reason: a site that inherits
620
620
  // the `generated = []` default silently reverts to the two-bucket guard that wedged the lane.
621
- const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
621
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
622
622
  const calls = [];
623
623
  const re = /commitPin\(\{/g;
624
624
  let m;
@@ -756,7 +756,7 @@ test('the post-upgrade net re-probes, rather than reusing the set from before th
756
756
  // A skill the NEW core adds is absent from the pre-upgrade answer, so reusing it would drop that
757
757
  // file into `other` and wedge the lane one release later — this defect again, wearing the next
758
758
  // version number. The heal, which runs BEFORE the bump, must keep using the pre-bump set.
759
- const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
759
+ const src = readFileSync(path.join(ROOT, 'scripts', 'gds', 'update-sweep.js'), 'utf8');
760
760
  const afterSpawn = src.split('const generatedAfter = laneGeneratedPaths(inst.dir);')[1];
761
761
  assert.ok(afterSpawn, 'the post-upgrade re-probe is gone');
762
762
  assert.equal((afterSpawn.match(/generated: generatedAfter,/g) || []).length, 2,
@@ -0,0 +1,100 @@
1
+ // tests/update_sweep_home.mjs — the update sweep lives in the core package and runs from it
2
+ // (task 1004307).
3
+ //
4
+ // WHY. The sweep used to live only under .claude/scheduled-tasks/, which the core package never
5
+ // ships, so cloudbongos.com's sweep ran a checkout frozen at 2026-08-25 and every later lane fix was
6
+ // inert there. It now lives at scripts/gds/update-sweep.js. These pin the three things that keep it
7
+ // runnable from node_modules/@bongos/core: it publishes, it needs nothing the package lacks (the
8
+ // .claude/hooks resolver is topology-excluded), and it reads the roster from where it is told,
9
+ // not from where its own file sits.
10
+ //
11
+ // Run: node --test tests/update_sweep_home.mjs
12
+
13
+ import { test } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import { createRequire } from 'node:module';
16
+ import { spawnSync } from 'node:child_process';
17
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const SWEEP = path.join(ROOT, 'scripts', 'gds', 'update-sweep.js');
25
+ const sweep = require(SWEEP);
26
+ const { isPublishable } = require('../scripts/gds/publish-manifest.js');
27
+
28
+ test('the sweep and every core file it requires are in the published package', () => {
29
+ for (const f of ['scripts/gds/update-sweep.js', 'scripts/gds/update-channel.js', 'scripts/gds/upgrade.js', 'src/module-api.js']) {
30
+ assert.equal(isPublishable(f), true, `${f} must publish, or the installed sweep cannot run`);
31
+ }
32
+ // The reason it moved: the old home never ships.
33
+ assert.equal(isPublishable('.claude/scheduled-tasks/core-update-subscription/subscribe.js'), false);
34
+ });
35
+
36
+ test('the sweep requires nothing the package lacks (no .claude/ path, no hook resolver)', () => {
37
+ const src = readFileSync(SWEEP, 'utf8');
38
+ const requires = [...src.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)].map((m) => m[1]);
39
+ for (const r of requires) {
40
+ assert.ok(!r.includes('.claude') && !r.includes('_core-resolve'), `update-sweep.js requires ${r}, which the core package does not ship`);
41
+ if (r.startsWith('.')) assert.equal(isPublishable(path.posix.join('scripts/gds', r).replace(/\\/g, '/')), true, `${r} must publish`);
42
+ }
43
+ });
44
+
45
+ test('its target-instance resolver searches the same packages, in the same order, as _core-resolve.js', () => {
46
+ const hook = readFileSync(path.join(ROOT, '.claude', 'hooks', '_core-resolve.js'), 'utf8');
47
+ const m = hook.match(/const CORE_PKG_NAMES = (\[[^\]]*\]);/);
48
+ assert.ok(m, '_core-resolve.js CORE_PKG_NAMES not found');
49
+ assert.deepEqual(sweep.CORE_PKG_NAMES, JSON.parse(m[1].replace(/'/g, '"')));
50
+ });
51
+
52
+ test('coreModulePathFrom finds a target instance\'s INSTALLED core, root first', () => {
53
+ const inst = mkdtempSync(path.join(tmpdir(), 'sweep-home-'));
54
+ const pkg = path.join(inst, 'node_modules', '@bongos', 'core', 'scripts', 'gds');
55
+ mkdirSync(pkg, { recursive: true });
56
+ writeFileSync(path.join(pkg, 'upgrade.js'), '');
57
+ assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(pkg, 'upgrade.js'));
58
+ mkdirSync(path.join(inst, 'scripts', 'gds'), { recursive: true });
59
+ writeFileSync(path.join(inst, 'scripts', 'gds', 'upgrade.js'), '');
60
+ assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/upgrade.js'), path.join(inst, 'scripts', 'gds', 'upgrade.js'), 'a monolith root wins, as in _core-resolve.js');
61
+ assert.equal(sweep.coreModulePathFrom(inst, 'scripts/gds/absent.js'), null);
62
+ });
63
+
64
+ test('run from elsewhere, it reads the roster named by --instance and says which core is sweeping', () => {
65
+ const rosterRoot = mkdtempSync(path.join(tmpdir(), 'sweep-roster-'));
66
+ mkdirSync(path.join(rosterRoot, 'config'));
67
+ writeFileSync(path.join(rosterRoot, 'config', 'update-subscriptions.json'),
68
+ JSON.stringify({ instances: [{ slug: 'held', dir: path.join(rosterRoot, 'nowhere'), channel: 'pinned' }] }));
69
+ const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
70
+ const r = spawnSync(process.execPath, [SWEEP, '--instance', rosterRoot], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
71
+ assert.equal(r.status, 0, r.stderr);
72
+ assert.match(r.stdout, new RegExp(`update-sweep · running from .+ \\(core \\d+\\.\\d+\\.\\d+\\) · roster ${rosterRoot.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')}`));
73
+ assert.match(r.stdout, /1 subscribed instance/);
74
+ assert.match(r.stdout, /held: pinned — skipped/);
75
+ });
76
+
77
+ test('without --instance it reads the roster in its working directory (the unit\'s WorkingDirectory)', () => {
78
+ const cwd = mkdtempSync(path.join(tmpdir(), 'sweep-cwd-'));
79
+ const r = spawnSync(process.execPath, [SWEEP], { cwd, encoding: 'utf8', env: { ...process.env, DATABASE_URL: '', PGDATABASE: '' } });
80
+ assert.equal(r.status, 0, r.stderr);
81
+ assert.match(r.stdout, /no roster file — nothing subscribed/);
82
+ assert.ok(r.stdout.includes(`roster ${cwd}`) || r.stdout.includes(`roster ${path.resolve(cwd)}`), r.stdout);
83
+ });
84
+
85
+ test('the subscribe.js shim runs the same body, with its instance root as the default roster root', () => {
86
+ const shim = require('../.claude/scheduled-tasks/core-update-subscription/subscribe.js');
87
+ assert.equal(shim, sweep, 'the shim re-exports the core body, so tests and callers see one sweep');
88
+ const src = readFileSync(path.join(ROOT, '.claude', 'scheduled-tasks', 'core-update-subscription', 'subscribe.js'), 'utf8');
89
+ assert.match(src, /requireCore\('scripts\/gds\/update-sweep\.js'\)/);
90
+ assert.match(src, /sweep\.main\(\{ instanceRoot: INSTANCE_ROOT \}\)/);
91
+ });
92
+
93
+ test('the recipe\'s drop-in names the file that really ships, and the safe database setting', () => {
94
+ const recipe = readFileSync(path.join(ROOT, 'docs', 'recipes', 'upgrading-the-core.md'), 'utf8');
95
+ const execs = [...recipe.matchAll(/node_modules\/@bongos\/core\/(scripts\/gds\/[\w-]+\.js)/g)].map((m) => m[1]);
96
+ assert.ok(execs.length >= 2, 'the drop-in and its dry run both name the installed sweep');
97
+ for (const rel of execs) assert.equal(rel, 'scripts/gds/update-sweep.js');
98
+ assert.match(recipe, /Environment=UPDATE_CHANNEL_DB=/);
99
+ assert.doesNotMatch(recipe, /Environment=PGDATABASE=/, 'a unit-level PGDATABASE leaks into tenant upgrades');
100
+ });
@@ -0,0 +1,264 @@
1
+ // tests/wizard_demo.mjs — the demo route: training wheels, a soft time box, and "keep
2
+ // your planet?" (task 1004420 / BV2.PS09, goal 1000121; spec D3 + D9).
3
+ //
4
+ // Done-when: a demo can be created, run and carried over. D3: the time box is SOFT — a
5
+ // countdown, then the question; work is never frozen. D9: a demo is a normal project
6
+ // marked "demo"; one nobody keeps goes offline after 30 days through the existing
7
+ // take-offline path, with the owner told first.
8
+ //
9
+ // Three layers, all RUN: modules/provisioning/demo.js (the marker, the defaults, the
10
+ // view, the answer, the sweep); the create route and the keep route over a mounted
11
+ // router; and the hub's demo controller sliced out of projects.html and driven in a vm.
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { test, after } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import vm from 'node:vm';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
24
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
25
+ const demo = require('../modules/provisioning/demo.js');
26
+ const DAY = 86400000;
27
+
28
+ // ── the module ───────────────────────────────────────────────────────────────
29
+
30
+ test('a demo asks for people and hours each, inside their limits, and nothing else', () => {
31
+ assert.deepEqual(demo.parseDemo(undefined), { ok: true, demo: null }, 'no demo field: an ordinary project');
32
+ assert.deepEqual(demo.parseDemo({ people: 3, hours_each: 4 }), { ok: true, demo: { people: 3, hours_each: 4 } });
33
+ assert.equal(demo.parseDemo({ people: 0, hours_each: 4 }).ok, false);
34
+ assert.equal(demo.parseDemo({ people: 13, hours_each: 4 }).ok, false);
35
+ assert.equal(demo.parseDemo({ people: 3, hours_each: 41 }).ok, false);
36
+ assert.equal(demo.parseDemo({ people: 3, hours_each: 4, ends_at: 'never' }).ok, false, 'a caller cannot name its own end');
37
+ assert.equal(demo.parseDemo([3, 4]).ok, false);
38
+ });
39
+
40
+ test('the first read says what fits in the builder-hours', () => {
41
+ assert.equal(demo.fitCheck(1, 4).verdict, 'tight');
42
+ assert.equal(demo.fitCheck(3, 4).verdict, 'small');
43
+ assert.equal(demo.fitCheck(3, 4).total, 12);
44
+ assert.equal(demo.fitCheck(5, 4).verdict, 'plenty');
45
+ });
46
+
47
+ test('the demo picks no rewards and all managed, and an owner\'s own answer wins', () => {
48
+ assert.deepEqual(demo.withDemoDetail(undefined), { reward_intents: [], framing: 'managed' });
49
+ assert.deepEqual(demo.withDemoDetail({ framing: 'hybrid', team_shape: 'small-team' }), { reward_intents: [], framing: 'hybrid', team_shape: 'small-team' });
50
+ assert.deepEqual({ ...demo.DEMO_SETTINGS }, { visibility: 'private', joinability: 'invite_only' }, 'private to the team');
51
+ });
52
+
53
+ test('the time box is soft: it only counts down and then asks (D3)', () => {
54
+ const start = new Date('2026-10-01T09:00:00Z');
55
+ const row = { demo: demo.demoRecord({ people: 3, hours_each: 4 }, start) };
56
+ assert.equal(row.demo.ends_at, '2026-10-01T13:00:00.000Z');
57
+ const mid = demo.demoView(row, new Date('2026-10-01T11:30:00Z'));
58
+ assert.equal(mid.remaining_seconds, 5400);
59
+ assert.equal(mid.time_up, false);
60
+ assert.equal(mid.builder_hours, 12);
61
+ const late = demo.demoView(row, new Date('2026-10-01T15:00:00Z'));
62
+ assert.equal(late.remaining_seconds, 0);
63
+ assert.equal(late.time_up, true, 'it asks; nothing in the view stops anything');
64
+ assert.equal(late.offline_on, '2026-10-31T09:00:00.000Z', '30 days after it started (D9)');
65
+ assert.equal(late.offline_soon, false);
66
+ assert.equal(demo.demoView(row, new Date('2026-10-29T09:00:00Z')).offline_soon, true, 'the owner is told from three days before');
67
+ assert.equal(demo.demoView({ demo: null }), null, 'an ordinary project has no demo view');
68
+ assert.equal(demo.demoView({ demo: { ...row.demo, outcome: 'carried' } }), null, 'a carried-over demo is a real project');
69
+ });
70
+
71
+ function fakeDb(rows = []) {
72
+ const writes = [];
73
+ return { writes, query: async (sql, params) => { if (/^\s*UPDATE/.test(sql)) writes.push(params); return { rows }; } };
74
+ }
75
+
76
+ test('keep your planet? is answered once', async () => {
77
+ const db = fakeDb();
78
+ const inst = { id: 9, demo: demo.demoRecord({ people: 2, hours_each: 3 }) };
79
+ const r = await demo.settleDemo(db, inst, 'carried');
80
+ assert.equal(r.ok, true);
81
+ assert.equal(JSON.parse(db.writes[0][1]).outcome, 'carried');
82
+ assert.equal((await demo.settleDemo(db, { ...inst, demo: r.demo }, 'kept')).reason, 'already_settled');
83
+ assert.equal((await demo.settleDemo(db, { id: 1, demo: null }, 'kept')).reason, 'not_a_demo');
84
+ assert.equal((await demo.settleDemo(db, inst, 'frozen')).reason, 'bad_outcome');
85
+ });
86
+
87
+ test('the sweep warns three days before and takes a demo nobody carried over offline at 30 days', async () => {
88
+ const started = new Date('2026-09-01T00:00:00Z');
89
+ const rows = [
90
+ { id: 1, owner_builder_id: 7, status: 'active', demo: demo.demoRecord({ people: 1, hours_each: 2 }, started) },
91
+ { id: 2, owner_builder_id: 7, status: 'active', demo: { ...demo.demoRecord({ people: 1, hours_each: 2 }, new Date('2026-09-04T00:00:00Z')), outcome: 'kept' } },
92
+ { id: 3, owner_builder_id: 7, status: 'active', demo: demo.demoRecord({ people: 1, hours_each: 2 }, new Date('2026-09-20T00:00:00Z')) },
93
+ ];
94
+ const torn = [];
95
+ const db = fakeDb(rows);
96
+ const out = await demo.sweepDemos(db, {
97
+ enqueueTeardown: async (_db, id) => { torn.push(id); },
98
+ recordEvent: async () => {},
99
+ now: new Date('2026-10-02T00:00:00Z'),
100
+ });
101
+ assert.deepEqual(torn, [1], 'only the one past 30 days goes offline, through the teardown path');
102
+ assert.equal(out.offline, 1);
103
+ assert.equal(out.warned, 1, 'the kept demo two days out is warned; the young one is left alone');
104
+ assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'demo.js'), 'utf8'), /COALESCE\(demo->>'outcome', ''\) <> 'carried'/, 'a carried-over demo is never swept');
105
+ });
106
+
107
+ // ── the routes ───────────────────────────────────────────────────────────────
108
+ const api = require('../src/module-api.js');
109
+ const provisioning = require('../modules/provisioning/provisioning.js');
110
+ let created = null;
111
+ let started = null;
112
+ provisioning.getInstanceBySlug = async () => null;
113
+ provisioning.getInstanceByDomain = async () => null;
114
+ provisioning.createInstance = async (_db, args) => {
115
+ created = args;
116
+ return { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: args.detail || {} }, created: true };
117
+ };
118
+ provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
119
+ provisioning.getOpenIntent = async () => null;
120
+ provisioning.recordEvent = async () => {};
121
+ demo.startDemoProject = async (_db, _p, id, d) => { started = { id, d }; return null; };
122
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
123
+ let stored = null;
124
+ provisioning.getInstanceById = async (_db, id) => (id === 41 ? { id: 41, owner_builder_id: 7, demo: stored } : null);
125
+ api.pool.query = async (sql, params) => { if (/UPDATE provisioning_instances SET demo/.test(sql)) stored = JSON.parse(params[1]); return { rows: [] }; };
126
+
127
+ const express = require('express');
128
+ const app = express();
129
+ app.use(express.json());
130
+ app.use((_req, res, next) => {
131
+ res.fail = (code, opts) => {
132
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
133
+ res.status(status).json({ error: { code, message: opts && opts.message } });
134
+ };
135
+ next();
136
+ });
137
+ app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
138
+ app.use('/api/bongos', require('../modules/provisioning/routes/demo.js')());
139
+ const server = app.listen(0);
140
+ after(() => server.close());
141
+ async function post(p, body) {
142
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
143
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos${p}`, {
144
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
145
+ });
146
+ return { status: res.status, body: await res.json() };
147
+ }
148
+
149
+ test('the create files a normal project marked demo, with the demo\'s picked answers under the owner\'s', async () => {
150
+ const r = await post('/provisioning/instances', { slug: 'reading-list', hosting_shape: 'byo-host', demo: { people: 3, hours_each: 4 }, detail: { team_shape: 'small-team' } });
151
+ assert.equal(r.status, 201, JSON.stringify(r.body));
152
+ assert.deepEqual(created.detail, { reward_intents: [], framing: 'managed', team_shape: 'small-team' });
153
+ assert.deepEqual(started, { id: 41, d: { people: 3, hours_each: 4 } }, 'the row is marked and made private');
154
+ started = null;
155
+ const plain = await post('/provisioning/instances', { slug: 'real-one', hosting_shape: 'byo-host' });
156
+ assert.equal(plain.status, 201);
157
+ assert.equal(started, null, 'an ordinary project is never marked');
158
+ assert.equal((await post('/provisioning/instances', { slug: 'bad-demo', hosting_shape: 'byo-host', demo: { people: 99, hours_each: 4 } })).status, 400);
159
+ });
160
+
161
+ test('keep your planet? over the route: the owner answers once', async () => {
162
+ stored = demo.demoRecord({ people: 3, hours_each: 4 }, new Date(Date.now() - 5 * 3600000));
163
+ const r = await post('/provisioning/instances/41/demo', { outcome: 'kept' });
164
+ assert.equal(r.status, 200, JSON.stringify(r.body));
165
+ assert.equal(stored.outcome, 'kept');
166
+ assert.equal(r.body.demo.outcome, 'kept');
167
+ assert.equal((await post('/provisioning/instances/41/demo', { outcome: 'carried' })).status, 409, 'answered once');
168
+ assert.equal((await post('/provisioning/instances/99/demo', { outcome: 'carried' })).status, 404, 'not yours, not found');
169
+ assert.equal((await post('/provisioning/instances/41/demo', { outcome: 'frozen' })).status, 400);
170
+ });
171
+
172
+ test('the module declares the route, the poller and its migration', () => {
173
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
174
+ assert.ok(manifest.contributes.routes.includes('demo'));
175
+ assert.ok(manifest.contributes.pollers.includes('demo-archive'));
176
+ assert.match(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'migrations', 'provisioning_035_demo.sql'), 'utf8'), /ADD COLUMN IF NOT EXISTS demo jsonb/);
177
+ const poller = require('../modules/provisioning/pollers/demo-archive.js');
178
+ assert.equal(typeof poller.start, 'function');
179
+ assert.equal(poller.INTERVAL_MS, 3600000);
180
+ });
181
+
182
+ // ── the hub, RUN ─────────────────────────────────────────────────────────────
183
+ const SLICE = HUB.slice(HUB.indexOf(' /* ── the demo route (task 1004420, BV2.PS09) ──'), HUB.indexOf(' /* ── field wiring ── */'));
184
+
185
+ test('the hub carries the server\'s limits and fit sentences exactly', () => {
186
+ assert.ok(SLICE.length > 1000, 'the demo controller was found');
187
+ const box = {};
188
+ vm.createContext(box);
189
+ vm.runInContext(SLICE.slice(SLICE.indexOf(' var DEMO_LIMITS'), SLICE.indexOf(' function demoHandle')).replace('var demoDraft = state.demo', 'var state = {}; var demoDraft = state.demo'), box);
190
+ assert.deepEqual(JSON.parse(JSON.stringify(box.DEMO_LIMITS)), { people: [...demo.DEMO_LIMITS.people], hours: [...demo.DEMO_LIMITS.hours_each] });
191
+ for (const [p, h] of [[1, 2], [3, 4], [5, 4], [12, 40]]) {
192
+ assert.deepEqual(JSON.parse(JSON.stringify(box.demoFit(p, h))), demo.fitCheck(p, h), `${p} × ${h}`);
193
+ }
194
+ });
195
+
196
+ class El {
197
+ constructor(id) {
198
+ this.id = id; this.hidden = false; this.value = ''; this.textContent = ''; this.attrs = {}; this.listeners = {};
199
+ const self = this; this.classList = { toggle() {} };
200
+ this._label = { hidden: false }; this.closest = () => self._label;
201
+ }
202
+ setAttribute(k, v) { this.attrs[k] = String(v); }
203
+ getAttribute(k) { return this.attrs[k] ?? null; }
204
+ addEventListener(t, fn) { this.listeners[t] = fn; }
205
+ focus() {}
206
+ }
207
+
208
+ function bootHub() {
209
+ const els = {};
210
+ const $ = (id) => (els[id] = els[id] || new El(id));
211
+ const steppers = [['people', '1'], ['people', '-1'], ['hours', '1'], ['hours', '-1']].map(([k, by]) => {
212
+ const b = new El(`step-${k}${by}`); b.attrs['data-demo-step'] = k; b.attrs['data-by'] = by; return b;
213
+ });
214
+ const shown = [];
215
+ const state = { demo: null, description: '', teamShape: null };
216
+ const sandbox = { $, state, saveDraft() {}, show: (n) => shown.push(n), root: { querySelectorAll: () => steppers } };
217
+ vm.createContext(sandbox);
218
+ vm.runInContext(SLICE, sandbox);
219
+ const click = (id) => $(id).listeners.click.call($(id));
220
+ return { $, state, shown, steppers, click, sandbox };
221
+ }
222
+
223
+ test('the door opens the setup, the steppers stay in their limits, and the first read follows them', () => {
224
+ const h = bootHub();
225
+ h.click('wizDemo');
226
+ assert.equal(h.$('forkIntro').hidden, true);
227
+ assert.equal(h.$('demoSetup').hidden, false);
228
+ assert.equal(h.$('demoRing').hidden, false, 'the time-box ring on the planet');
229
+ assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'true');
230
+ for (let i = 0; i < 20; i += 1) h.steppers[0].listeners.click.call(h.steppers[0]);
231
+ assert.equal(h.$('demoPeople').textContent, '12', 'people stop at twelve');
232
+ for (let i = 0; i < 20; i += 1) h.steppers[3].listeners.click.call(h.steppers[3]);
233
+ assert.equal(h.$('demoHours').textContent, '1', 'hours stop at one');
234
+ assert.equal(h.$('demoFitSum').textContent, '12 people × 1 hour = 12 builder-hours.');
235
+ assert.equal(h.$('demoFitVerdict').textContent, demo.fitCheck(12, 1).sentence);
236
+ h.click('demoBack1');
237
+ assert.equal(h.$('forkIntro').hidden, false, 'Back returns to the two doors');
238
+ assert.equal(h.$('wizDemo').attrs['aria-expanded'], 'false');
239
+ });
240
+
241
+ test('Name my demo planet carries the demo into the ordinary wizard: the idea, the team, the invites', () => {
242
+ const h = bootHub();
243
+ h.click('wizDemo');
244
+ h.$('demoIdea').value = 'A shared reading list.';
245
+ h.$('demoIdea').listeners.input.call(h.$('demoIdea'));
246
+ h.click('demoNext1');
247
+ assert.equal(h.$('demoReady').hidden, false);
248
+ h.$('demoInvite1').value = '@avery';
249
+ h.$('demoInvite2').value = 'not a handle!';
250
+ h.click('demoStart');
251
+ assert.deepEqual(JSON.parse(JSON.stringify(h.state.demo)), { people: 3, hours_each: 4, idea: 'A shared reading list.', invites: ['avery'] });
252
+ assert.equal(h.state.description, 'A shared reading list.', 'the idea is the description');
253
+ assert.equal(h.state.teamShape, 'small-team');
254
+ assert.deepEqual(h.shown, [3], 'on to the name step');
255
+ });
256
+
257
+ test('the create sends the demo, the review names it, and the project page asks the question', () => {
258
+ assert.match(HUB, /demo: state\.demo \? \{ people: state\.demo\.people, hours_each: state\.demo\.hours_each \} : undefined,/);
259
+ assert.match(HUB, /if \(state\.demo\) \{\n\s*rows\.unshift\(\['Demo',/);
260
+ assert.match(HUB, /<p class="demoKeepH">Keep your planet\?<\/p>/);
261
+ assert.match(HUB, /data-demo-settle="carried">Carry it over</);
262
+ assert.match(HUB, /data-demo-settle="kept">Keep it as a demo</);
263
+ assert.match(HUB, /\$\('mDemoKeep'\)\.hidden = !\(d\.time_up && !d\.outcome\);/, 'the question waits for the time box (D3)');
264
+ });
@@ -113,24 +113,26 @@ test('Proceed is refused until the box is ticked, and the refusal says why in th
113
113
  assert.equal(given.ok, true, 'a tick goes on');
114
114
  });
115
115
 
116
- test('the fork has no clause of its own — its doors are the answer', () => {
116
+ test('the fork refuses nothing — its doors are the answer, and Begin clears a demo started earlier', () => {
117
117
  assert.equal(runValidate(2, false).ok, true, 'the fork asks nothing a validator could refuse');
118
- assert.ok(!validateSlice.includes('if (step === 2) {'), 'and no clause for step 2 exists');
118
+ // task 1004420: "Begin planet creation" is a real project, so a demo the founder
119
+ // set up and then walked back from must not ride the create
120
+ assert.match(validateSlice, /if \(step === 2\) \{ state\.demo = null; return true; \}/);
119
121
  });
120
122
 
121
123
  // ── the wiring, executed ───────────────────────────────────────────────────────
122
124
  const WIRE_START = " $('termsAck').checked = !!state.termsAck;";
123
- const WIRE_END = ' /* ── field wiring ── */';
125
+ const WIRE_END = ' /* ── the demo route (task 1004420, BV2.PS09) ──';
124
126
  const wireSlice = SCRIPT.includes(WIRE_START) ? SCRIPT.slice(SCRIPT.indexOf(WIRE_START), SCRIPT.indexOf(WIRE_END)) : null;
125
127
 
126
128
  function runWiring(initialAck) {
127
129
  const handlers = {};
128
130
  const mk = (id) => ({
129
- id, checked: false, hidden: id === 'demoSoon', textContent: '', className: '', attrs: {},
131
+ id, checked: false, hidden: false, textContent: '', className: '', attrs: {},
130
132
  addEventListener(type, fn) { handlers[id + ':' + type] = fn; },
131
133
  setAttribute(k, v) { this.attrs[k] = v; },
132
134
  });
133
- const els = { termsAck: mk('termsAck'), wizDemo: mk('wizDemo'), demoSoon: mk('demoSoon'), 'h-terms': mk('h-terms') };
135
+ const els = { termsAck: mk('termsAck'), 'h-terms': mk('h-terms') };
134
136
  let saves = 0;
135
137
  const sandbox = { $: (id) => els[id], state: { termsAck: initialAck }, saveDraft: () => { saves += 1; } };
136
138
  sandbox.globalThis = sandbox;
@@ -141,7 +143,7 @@ function runWiring(initialAck) {
141
143
 
142
144
  test('the front door\'s wiring is still findable', () => {
143
145
  assert.ok(wireSlice && wireSlice.length > 100, 'the wiring block was not found in projects.html');
144
- assert.match(wireSlice, /\$\('wizDemo'\)\.addEventListener\('click'/, 'the demo door is part of the slice');
146
+ assert.match(wireSlice, /\$\('termsAck'\)\.addEventListener\('change'/, 'the tick is part of the slice');
145
147
  });
146
148
 
147
149
  test('the tick mirrors into the draft both ways, and a resumed draft paints its own answer', () => {
@@ -156,17 +158,10 @@ test('the tick mirrors into the draft both ways, and a resumed draft paints its
156
158
  assert.equal(saves(), 2, 'each change is saved');
157
159
  });
158
160
 
159
- test('the demo door opens a line saying the demo is not open yet, and says so to a screen reader', () => {
160
- const { els, handlers } = runWiring(false);
161
- assert.equal(els.demoSoon.hidden, true, 'hidden at rest');
162
- handlers['wizDemo:click'].call(els.wizDemo);
163
- assert.equal(els.demoSoon.hidden, false, 'the placeholder opens');
164
- assert.equal(els.wizDemo.attrs['aria-expanded'], 'true');
165
- handlers['wizDemo:click'].call(els.wizDemo);
166
- assert.equal(els.demoSoon.hidden, true, 'and closes again');
167
- assert.equal(els.wizDemo.attrs['aria-expanded'], 'false');
168
- assert.match(FORK, /id="wizDemo" aria-controls="demoSoon" aria-expanded="false"/, 'the door names what it opens');
169
- assert.match(FORK, /<p class="demoSoon" id="demoSoon" hidden>The demo isn’t open yet\./, 'baked markup, never built by script');
161
+ test('the demo door names what it opens: the demo route\'s setup screen (task 1004420; run in tests/wizard_demo.mjs)', () => {
162
+ assert.match(FORK, /id="wizDemo" aria-controls="demoSetup" aria-expanded="false"/, 'the door names what it opens');
163
+ assert.match(FORK, /<div id="demoSetup" class="demoView" hidden>/, 'baked markup, hidden at rest, never built by script');
164
+ assert.doesNotMatch(FORK, /demoSoon|isn’t open yet/, 'the placeholder that said the demo was not open is gone');
170
165
  });
171
166
 
172
167
  // ── screen 2 · the fork ─────────────────────────────────────────────────────────
@@ -186,7 +181,7 @@ test('screen 2 is the door pill: two equal doors in one object, the accent only
186
181
  });
187
182
 
188
183
  test('the seed stands on the world\'s own field plate — nothing drawn but hairlines', () => {
189
- assert.match(FORK, /<div class="forkStage" aria-hidden="true">/, 'decorative, so hidden from a screen reader');
184
+ assert.match(FORK, /<div class="forkStage" id="forkStage" aria-hidden="true">/, 'decorative, so hidden from a screen reader');
190
185
  assert.match(FORK, /<img class="forkSeed" src="\/assets\/world\/r2-chrome-blob-one-cut\.webp" alt="">/, 'a real plate, root-absolute so it is stamped');
191
186
  assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', 'r2-chrome-blob-one-cut.webp')), 'and it exists on disk');
192
187
  assert.match(STYLE, /\.forkStage\{[^}]*background:var\(--sky-bg\) var\(--field-plate\) center\/cover no-repeat;/, 'the field is the plate token, not a new image');
@@ -197,9 +192,10 @@ test('the seed stands on the world\'s own field plate — nothing drawn but hair
197
192
  assert.doesNotMatch(block, /#[0-9a-f]{3,8}\b|box-shadow:(?!none)|linear-gradient/i, 'every colour a token, no shadow, no gradient');
198
193
  });
199
194
 
200
- test('the two routes are described honestly for today: the demo says coming soon, creation promises no questions that do not exist yet', () => {
195
+ test('the two routes are described honestly for today: the demo is open (task 1004420), creation promises no questions that do not exist yet', () => {
201
196
  const routes = (FORK.match(/<div class="forkRoutes">[\s\S]*$/) || [''])[0];
202
- assert.match(routes, /Route 1 · demo · coming soon/);
197
+ assert.match(routes, /Route 1 · demo<\/p>/);
198
+ assert.doesNotMatch(routes, /coming soon/, 'the demo is built, so the route no longer says it is coming');
203
199
  assert.match(routes, /Route 2 · planet creation/);
204
200
  assert.doesNotMatch(routes, /seven questions/i, 'the seven planet questions are PS07\'s — the copy must not promise them before they ship');
205
201
  assert.doesNotMatch(FORK, /\bBongos\b(?! pair)/, 'no product name baked where a child instance would ship it');