@bongos/core 1.25.4 → 1.25.6

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 (47) hide show
  1. package/.bongos-core.json +77 -47
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +12 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +239 -3
  8. package/docs/api-reference.md +8 -5
  9. package/docs/copy-inventory.md +12 -12
  10. package/docs/copy-registry.json +13 -13
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +49 -52
  13. package/modules/hall-ui/public/shell-game.js +34 -3
  14. package/modules/hall-ui/public/welcome-boot.js +27 -11
  15. package/modules/hall-ui/records/start-screen.md +10 -0
  16. package/modules/onboarding/genesis-home.js +35 -1
  17. package/modules/onboarding/routes/onboarding.js +7 -2
  18. package/modules/provisioning/catalog-bridge.js +3 -0
  19. package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
  20. package/modules/provisioning/module.json +1 -0
  21. package/modules/provisioning/paid-shape-gate.js +3 -1
  22. package/modules/provisioning/provisioning.js +3 -3
  23. package/modules/provisioning/routes/provisioning.js +11 -4
  24. package/modules/provisioning/routes/update-subscription.js +51 -0
  25. package/modules/provisioning/test-project.js +65 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +20 -0
  29. package/scripts/bongos/government-walk.js +2 -2
  30. package/scripts/bongos/update-channel.js +27 -4
  31. package/scripts/bongos/update-sweep.js +17 -3
  32. package/src/bongos/api-errors.js +2 -0
  33. package/src/bongos/db.js +43 -0
  34. package/src/bongos/route-rank-check.js +1 -0
  35. package/src/bongos/routes/builders.js +37 -0
  36. package/src/branding.js +7 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/builder_card_requirement.mjs +75 -0
  39. package/tests/currency_label.mjs +3 -1
  40. package/tests/hall_start_screen.mjs +62 -8
  41. package/tests/provision_settings_apply.mjs +2 -1
  42. package/tests/provisioning_paid_shape_gate.mjs +16 -0
  43. package/tests/provisioning_settings_env.mjs +2 -2
  44. package/tests/provisioning_update_subscription_route.mjs +141 -0
  45. package/tests/test_project.mjs +122 -0
  46. package/tests/update_channel_db.mjs +24 -0
  47. package/tests/wizard_demo.mjs +25 -4
@@ -20,6 +20,7 @@
20
20
  "core-upgrade",
21
21
  "env-manifest",
22
22
  "repo-private",
23
+ "update-subscription",
23
24
  "render-standup",
24
25
  "look",
25
26
  "demo",
@@ -104,6 +104,7 @@ const ONE_FREE_SHAPES = Object.freeze(new Set(['cloud-host']));
104
104
  // the response closes, so a second request sent meanwhile — from any web process — is
105
105
  // refused (the wizard never sends two). ./free-place-lock.js says how it is held.
106
106
  const freePlace = require('./free-place-lock');
107
+ const { isTestProject } = require('./test-project');
107
108
 
108
109
  // Shapes that are not requestable through this route AT ALL — no permission admits them,
109
110
  // because there is no rank at which "ask the API for a second control plane" is a
@@ -159,7 +160,8 @@ function shapeAuthority(rawShape) {
159
160
  async function otherLiveProjects(db, builderId, shape, rawSlug) {
160
161
  const slug = String(rawSlug || '').trim().toLowerCase();
161
162
  const rows = await provisioning.listInstancesForOwner(db, builderId);
162
- return rows.filter((r) => r.hosting_shape === shape && r.status !== 'torn_down' && r.slug !== slug);
163
+ // A test project holds no free place (task 1005036): it is for trying features, and only an archon makes one.
164
+ return rows.filter((r) => r.hosting_shape === shape && r.status !== 'torn_down' && r.slug !== slug && !isTestProject(r));
163
165
  }
164
166
 
165
167
  // requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
@@ -23,7 +23,7 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
23
23
  // pure declarations beside this file, for the same reason: storage and the read
24
24
  // projection both have to read them.
25
25
  const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
- const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); // the demo marker's owner view (task 1004420)
26
+ const { softLimitsFor } = require('./soft-limits'); const { demoView } = require('./demo'); const { isTestProject, TEST_COMPANION_ENV } = require('./test-project'); // the demo marker's owner view (task 1004420); the test mark (task 1005036)
27
27
  const { dnsResolves, recordDnsResolved, lookupAnswers } = require('./dns-resolves'); // has the platform's lookup seen the address answer (task 1004505)
28
28
  const { LOOK_SETTINGS_VOCAB, LOOK_DEFAULTS, LOOK_COMPANION_ENV } = require('./look'); const { BIRTH_COMPANION_ENV } = require('./birth'); // born into genesis (task 1004506)
29
29
 
@@ -453,7 +453,7 @@ function publicInstance(row) {
453
453
  detail,
454
454
  // Which startup options those answers hide, grey or annotate, each with its reason
455
455
  // (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
456
- soft_limits: softLimitsFor(detail), demo: demoView(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
456
+ soft_limits: softLimitsFor(detail), demo: demoView(row), is_test: isTestProject(row), // null unless a demo nobody carried over (task 1004420, ./demo.js)
457
457
  // The creation picker's module set (task 1002339): the always-on core, what
458
458
  // the owner ends up with, and whether that came from their own toggles or
459
459
  // from the type's starter bundle because they never answered. Resolved, not
@@ -513,7 +513,7 @@ function settingsEnvVars(row, prefix) {
513
513
  // cutoff and every unresolved review counts, which is the right answer for a
514
514
  // project that has been strict since it stood up.
515
515
  const SETTINGS_COMPANION_ENV = Object.freeze({
516
- ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js)
516
+ ARTIST_GATE_SINCE: (row) => artistGateStrictSince(row) || '', ...LOOK_COMPANION_ENV, ...BIRTH_COMPANION_ENV, ...TEST_COMPANION_ENV, // + accent, logo (look.js); birth, demo box (birth.js); test mark (test-project.js)
517
517
  });
518
518
 
519
519
  // The web.env block the runner writes at STANDUP (task 1003139): a comment that
@@ -64,7 +64,7 @@ const catalogBridge = require('../catalog-bridge');
64
64
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
65
65
  const { callbackPage } = require('./callback-page'); const githubSetup = require('../github-setup');
66
66
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
67
- const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth');
67
+ const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators'); const demo = require('../demo'); const birth = require('../birth'); const testProject = require('../test-project');
68
68
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
69
69
  const { settingsPush, failPushConflict } = require('../settings-push'); // a settings save's push + receipt, shared with routes/look.js (task 1004421)
70
70
  const { requireProjectOwner, projectActor, isProjectOwner } = require('../route-helpers'); // the owner-or-archon gate (task 1004159, ADR 0328)
@@ -164,6 +164,10 @@ function ownerInstance(row) {
164
164
  function fleetInstance(row) {
165
165
  return {
166
166
  owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
167
+ // Fleet-only (task 1004150): which rows take unattended upgrades, and under what rule. Kept off
168
+ // publicInstance, which feeds owner reads — who auto-upgrades is an operator's question.
169
+ update_channel: row.update_channel || 'patch',
170
+ update_subscribed: row.update_subscribed === true,
167
171
  ...ownerInstance(row),
168
172
  };
169
173
  }
@@ -357,7 +361,7 @@ module.exports = function provisioningRoutes() {
357
361
  // exactly the set the panel preselected. An explicit [] is the different
358
362
  // answer "none of them". maxItems is a sanity ceiling well above a catalog
359
363
  // that is three modules wide today.
360
- modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, genesis: { type: 'boolean' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
364
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 }, terms_acknowledged: { type: 'boolean' }, demo: { type: 'object' }, ship_mode: { type: 'string', enum: ['auto', 'manual'] }, genesis: { type: 'boolean' }, test: { type: 'boolean' }, // the wizard's terms tick (task 1004417, ../terms-ack.js); the demo route's { people, hours_each } (task 1004420, ../demo.js)
361
365
  })) return;
362
366
  const body = req.body || {};
363
367
  const detailFault = badProjectDetail(body.detail);
@@ -368,6 +372,8 @@ module.exports = function provisioningRoutes() {
368
372
  if (screened) return res.fail('screened_out', screened);
369
373
  const demoAsk = demo.parseDemo(body.demo); // a demo is a normal project marked demo, its picked answers under the owner's own (task 1004420)
370
374
  if (!demoAsk.ok) return res.fail('bad_demo', { status: 400, message: demoAsk.reason });
375
+ const testAsk = testProject.parseTestAsk(body, req.builder); // a test project: archon only; genesis only on one (task 1005036, ../test-project.js)
376
+ if (!testAsk.ok) return res.fail(testAsk.code, { status: testAsk.status, message: testAsk.message });
371
377
  const slug = String(body.slug || '').trim().toLowerCase();
372
378
  if (!provisioning.isValidSlug(slug)) {
373
379
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
@@ -473,8 +479,9 @@ module.exports = function provisioningRoutes() {
473
479
  // provision intent exists, so the runner's web.env already carries the marker and the demo's box.
474
480
  // Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
475
481
  // Genesis is OFF by default (owner, 2026-11-01): a new project opens as the full hall. Only a demo (its
476
- // time box lives on the birth marker) or an explicit `genesis: true` (the test toggle) is born into it.
477
- if (created && (demoAsk.demo || body.genesis === true)) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
482
+ // time box lives on the birth marker) or a test project asking for genesis is born into it.
483
+ if (created && (demoAsk.demo || testAsk.genesis)) await birth.stampBirth(pool, instance.id).catch((err) => log.error('[provisioning] birth marker not stamped', err));
484
+ if (created && testAsk.test) await testProject.stampTestProject(pool, instance.id).catch((err) => log.error('[provisioning] test mark not stamped', err));
478
485
  // The wizard's ship mode (task 1004629) rides beside it; a miss means the project asks at its first ship.
479
486
  if (created) await birth.stampShipMode(pool, instance.id, body.ship_mode).catch((err) => log.error('[provisioning] ship mode not stamped', err));
480
487
  const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
@@ -0,0 +1,51 @@
1
+ // modules/provisioning/routes/update-subscription.js — POST /provisioning/instances/:id/update-subscription
2
+ // (task 1004150, ADR 0325 decision 5).
3
+ //
4
+ // WHY. Whether the nightly sweep moves an instance on its own was only a line in
5
+ // config/update-subscriptions.json, so the fleet roster could not report it and a restored
6
+ // backup brought back every row with an empty roster. The flag lives on the row now
7
+ // (provisioning_037); this is how an operator arms or disarms it. It changes a rule, never the
8
+ // core: nothing is queued.
9
+ //
10
+ // Its own routes file (declared in module.json) because routes/provisioning.js sits at the
11
+ // size ratchet — the routes/repo-private.js precedent.
12
+ 'use strict';
13
+
14
+ const express = require('express');
15
+ const api = require('../../../src/module-api');
16
+ const { pool, validateOrRespond, parseId } = api;
17
+ const provisioning = require('../provisioning');
18
+ const { failFrom } = require('../public-refusal');
19
+
20
+ const log = api.logger('provisioning');
21
+
22
+ module.exports = function buildUpdateSubscriptionRouter() {
23
+ const router = express.Router();
24
+
25
+ // Body: { subscribed: boolean }. Gate: the 'provisioning.fleet.manage' permission — the fleet's own.
26
+ router.post('/provisioning/instances/:id/update-subscription', api.requireBuilder, api.requirePermission('provisioning.fleet.manage'), async (req, res) => {
27
+ const id = parseId(req, res);
28
+ if (!id) return;
29
+ if (validateOrRespond(req, res, { subscribed: { type: 'boolean', required: true } }, { strict: true })) return;
30
+ try {
31
+ const inst = await provisioning.getInstanceById(pool, id);
32
+ if (!inst) return res.fail('instance_not_found', 404);
33
+ const subscribed = req.body.subscribed === true;
34
+ const was = inst.update_subscribed === true;
35
+ if (was !== subscribed) {
36
+ await pool.query('UPDATE provisioning_instances SET update_subscribed = $2, updated_at = now() WHERE id = $1', [id, subscribed]);
37
+ await provisioning.recordEvent(pool, {
38
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'update-subscription-changed',
39
+ detail: `unattended patch upgrades ${subscribed ? 'armed' : 'disarmed'} by ${req.builder.github_login || req.builder.id}`,
40
+ actor: `api:fleet:${req.builder.id}`,
41
+ }).catch(() => {});
42
+ }
43
+ res.json({ ok: true, update_subscribed: subscribed, previous: was, changed: was !== subscribed });
44
+ } catch (err) {
45
+ log.error('[provisioning] POST /provisioning/instances/:id/update-subscription', err);
46
+ failFrom(res, err, 'update_subscription_failed');
47
+ }
48
+ });
49
+
50
+ return router;
51
+ };
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ // modules/provisioning/test-project.js — a TEST project: one made to try features that are not
4
+ // live yet (task 1005036, idea 1001299; owner, 2026-11-01).
5
+ //
6
+ // A test project is an ordinary project with a mark on it. The mark changes four things and
7
+ // nothing else: it is labelled, it never reaches the public map, it does not count toward a
8
+ // builder's one free hosted project, and it is the only kind that may switch not-yet-live
9
+ // features on (genesis today; the opt-in modules through the hall's own toggle). A project
10
+ // without the mark behaves exactly as before, which is the property the tests pin.
11
+ //
12
+ // WHO MAY MAKE ONE. Archon only — a plain rank test, as route-helpers.js's archon override
13
+ // is, never a permission atom. Unfinished features are for the people building the platform,
14
+ // and a test project is not a way around the one-free-project limit for anyone else.
15
+ //
16
+ // WHERE THE MARK LIVES. The reserved settings-jsonb key `test_project`, stamped once on the
17
+ // row the create route just made (never on an existing row), like `founding_birth` beside it.
18
+ // It is out of SETTINGS_VOCAB, so the settings PATCH cannot write it and effectiveSettings
19
+ // never projects it. It reaches the instance as the companion TEST_PROJECT, which
20
+ // src/branding.js maps onto `project.test` — that is how the hall labels itself and how the
21
+ // instance refuses the feature switches on a project that is not a test one.
22
+
23
+ const TEST_KEY = 'test_project';
24
+
25
+ const storedOf = (row) => (row && row.settings && typeof row.settings === 'object' && !Array.isArray(row.settings) ? row.settings : {});
26
+
27
+ // isTestProject(row) — PURE. True only for a row stamped 'on'.
28
+ function isTestProject(row) {
29
+ return storedOf(row)[TEST_KEY] === 'on';
30
+ }
31
+
32
+ // parseTestAsk(body, builder) — PURE. What the create request asks for: { ok: true, test, genesis },
33
+ // or { ok: false, status, code, message }. `genesis: true` is the test project's way of being
34
+ // born with genesis on, so it means nothing without `test: true`.
35
+ function parseTestAsk(body, builder) {
36
+ const b = body && typeof body === 'object' ? body : {};
37
+ const test = b.test === true;
38
+ const genesis = b.genesis === true;
39
+ if (genesis && !test) {
40
+ return { ok: false, status: 400, code: 'genesis_needs_test', message: 'Genesis is only switched on for a test project: send "test": true as well.' };
41
+ }
42
+ if (test && !(builder && builder.rank === 'archon')) {
43
+ return { ok: false, status: 403, code: 'test_project_forbidden', message: 'Only an archon can create a test project.' };
44
+ }
45
+ return { ok: true, test, genesis };
46
+ }
47
+
48
+ // stampTestProject(db, instanceId) — the create route's one write, on the row it just made.
49
+ // Write-once: a mark already there is kept (the WHERE says so).
50
+ async function stampTestProject(db, instanceId) {
51
+ await db.query(
52
+ `UPDATE provisioning_instances
53
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, 'on')
54
+ WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
55
+ [instanceId, TEST_KEY],
56
+ );
57
+ }
58
+
59
+ // The companion env (SETTINGS_COMPANION_ENV): 'on' for a test project, empty for every other,
60
+ // so the runner's in-place patch never leaves a stale line behind.
61
+ const TEST_COMPANION_ENV = Object.freeze({
62
+ TEST_PROJECT: (row) => (isTestProject(row) ? 'on' : ''),
63
+ });
64
+
65
+ module.exports = { isTestProject, parseTestAsk, stampTestProject, TEST_COMPANION_ENV };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.4",
3
+ "version": "1.25.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.4",
9
+ "version": "1.25.6",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.4",
3
+ "version": "1.25.6",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -11718,5 +11718,25 @@
11718
11718
  "id": "1005249",
11719
11719
  "text": "Your details now sit on your character at the bottom left of the game: hover to see them, click to open your full profile without leaving the scene. The old profile card at the top right is gone."
11720
11720
  }
11721
+ ],
11722
+ "1.25.5": [
11723
+ {
11724
+ "id": "1004748",
11725
+ "text": "Archon can now switch on a card requirement for a builder who has registered a card, with every change recorded."
11726
+ },
11727
+ {
11728
+ "id": "1004150",
11729
+ "text": "The fleet view can now show which projects get automatic patch upgrades, and the nightly upgrade run says so out loud when it has nothing to do."
11730
+ }
11731
+ ],
11732
+ "1.25.6": [
11733
+ {
11734
+ "id": "1005036",
11735
+ "text": "An archon can now create a test project: a project marked for trying features that are not finished. It stays off the public map, does not use up anyone's one free hosted project, and is the only kind where the guided setup ca"
11736
+ },
11737
+ {
11738
+ "id": "1005219",
11739
+ "text": "Opening the builders' hall now starts on the Game or Classic choice. Signed out, your pick takes you to sign in and then straight to that menu."
11740
+ }
11721
11741
  ]
11722
11742
  }
@@ -610,8 +610,8 @@ async function runWalk() {
610
610
  const instanceRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'government-walk-'));
611
611
  process.env.BONGOS_INSTANCE_ROOT = instanceRoot;
612
612
  delete process.env.DATABASE_URL; // the walk never touches a real database
613
- // The modules' info lines (a fence switched, a budget set) would bury the table.
614
- if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn';
613
+ process.env.BONGOS_TEST_PROJECT = 'on'; // a TEST project: only one starts genesis by hand (task 1005036)
614
+ if (!process.env.LOG_LEVEL) process.env.LOG_LEVEL = 'warn'; // info lines would bury the table
615
615
 
616
616
  const req = (rel) => require(path.join(ROOT, rel));
617
617
  const api = req('src/module-api.js');
@@ -333,13 +333,14 @@ function loadSubscriptions({ instanceDir = process.cwd(), fsImpl = fs, file = SU
333
333
  const CHANNEL_DB_ENV = 'UPDATE_CHANNEL_DB';
334
334
  const CHANNEL_SOURCE_DEPLOY = 'deploy';
335
335
  const CHANNEL_SOURCE_ROSTER = 'roster';
336
- const DB_CHANNELS_SQL = 'SELECT slug, update_channel FROM provisioning_instances';
336
+ const DB_CHANNELS_SQL = 'SELECT slug, update_channel, update_subscribed FROM provisioning_instances';
337
337
  const DB_CHANNELS_TIMEOUT_MS = 15000;
338
338
 
339
339
  // PURE. psql's stderr reduced to a class that is safe to log.
340
340
  function psqlFailureClass(stderr) {
341
341
  const s = String(stderr || '');
342
342
  if (/relation .*provisioning_instances.* does not exist/i.test(s)) return 'no provisioning_instances table (provisioning module off?)';
343
+ if (/column .*update_subscribed.* does not exist/i.test(s)) return 'no update_subscribed column (migration provisioning_037 not applied)';
343
344
  if (/column .*update_channel.* does not exist/i.test(s)) return 'no update_channel column (migration provisioning_029 not applied)';
344
345
  if (/authentication failed|role .* does not exist|permission denied/i.test(s)) return 'the sweep\'s database login was refused';
345
346
  if (/could not connect|connection refused|no such file or directory|could not translate host/i.test(s)) return 'database unreachable';
@@ -366,11 +367,32 @@ function readDbChannels({ env = process.env, run = spawnSync } = {}) {
366
367
  }
367
368
  if (r.status !== 0) return { ok: false, reason: `psql exit ${r.status}: ${psqlFailureClass(r.stderr)}` };
368
369
  const channels = new Map();
370
+ const subscribed = new Map();
369
371
  for (const line of String(r.stdout || '').split('\n')) {
370
- const [slug, ch] = line.split('\t');
371
- if (slug && ch) channels.set(slug.trim(), ch.trim());
372
+ const [slug, ch, sub] = line.split('\t');
373
+ if (!slug || !ch) continue;
374
+ channels.set(slug.trim(), ch.trim());
375
+ subscribed.set(slug.trim(), String(sub || '').trim() === 't');
372
376
  }
373
- return { ok: true, channels };
377
+ return { ok: true, channels, subscribed };
378
+ }
379
+
380
+ // PURE. Merge the two places a subscription can be written: the project row
381
+ // (provisioning_instances.update_subscribed, task 1004150) and the roster file. The file is an
382
+ // OVERRIDE — an entry in it is subscribed whatever the row says, so an operator's current arming
383
+ // is not dropped — but a row subscribed with NO file entry can't be upgraded (the file carries the
384
+ // `dir`), and it is returned in `unreachable` rather than vanishing, because "subscribed on the
385
+ // fleet view, never touched by the sweep" is the silent failure this column exists to end.
386
+ function applyDbSubscriptions(instances, db) {
387
+ const subscribed = db && db.ok && db.subscribed instanceof Map ? db.subscribed : null;
388
+ const slugs = new Set(instances.map((i) => i.slug));
389
+ const out = instances.map((inst) => {
390
+ if (!subscribed) return { ...inst, subscribedSource: CHANNEL_SOURCE_ROSTER };
391
+ if (subscribed.get(inst.slug) === true) return { ...inst, subscribedSource: CHANNEL_SOURCE_DEPLOY };
392
+ return { ...inst, subscribedSource: CHANNEL_SOURCE_ROSTER, subscribedNote: 'subscribed by the roster file only (the project row says not subscribed)' };
393
+ });
394
+ const unreachable = subscribed ? [...subscribed].filter(([slug, on]) => on && !slugs.has(slug)).map(([slug]) => slug) : [];
395
+ return { instances: out, unreachable };
374
396
  }
375
397
 
376
398
  // PURE. Give each roster entry the channel it is actually under, and say where it came from:
@@ -496,6 +518,7 @@ module.exports = {
496
518
  readDbChannels,
497
519
  CHANNEL_DB_ENV,
498
520
  applyDbChannels,
521
+ applyDbSubscriptions,
499
522
  channelLine,
500
523
  psqlFailureClass,
501
524
  CHANNEL_SOURCE_DEPLOY,
@@ -593,16 +593,29 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
593
593
  // CONTROL PLANE's database — this process's own env, never an entry's `env`, which points at that
594
594
  // instance's database. A core too old to carry the reader keeps the roster, as before.
595
595
  const db = typeof channel.readDbChannels === 'function' ? channel.readDbChannels({ env: process.env }) : null;
596
- const instances = db ? channel.applyDbChannels(loaded.instances, db) : loaded.instances;
597
- if (db && !db.ok && instances.length) console.log(` • update rules: read from the roster — ${db.reason}`);
596
+ const withChannels = db ? channel.applyDbChannels(loaded.instances, db) : loaded.instances;
597
+ if (db && !db.ok && withChannels.length) console.log(` • update rules: read from the roster — ${db.reason}`);
598
+ // Who is subscribed is also on the project row (task 1004150). The roster file stays an override.
599
+ const merged = db && typeof channel.applyDbSubscriptions === 'function'
600
+ ? channel.applyDbSubscriptions(withChannels, db) : { instances: withChannels, unreachable: [] };
601
+ const instances = merged.instances;
598
602
 
599
603
  const roster = only ? instances.filter((i) => i.slug === only || i.dir === only) : instances;
600
604
  const mode = apply ? 'apply' : 'dry-run';
601
605
  console.log(`core-update-subscription (${mode}) — ${roster.length} subscribed instance(s)${present ? '' : ' [no roster file — nothing subscribed]'}`);
602
- for (const inst of roster) if (inst.channelNote) console.log(channel.channelLine(inst));
606
+ for (const inst of roster) {
607
+ if (inst.channelNote) console.log(channel.channelLine(inst));
608
+ if (inst.subscribedNote) console.log(` • ${inst.slug}: ${inst.subscribedNote}`);
609
+ }
603
610
  if (only && roster.length === 0) console.error(` ! --only ${only} matched no subscribed instance`);
604
611
 
605
612
  let upgraded = 0, skipped = 0, failed = 0, upToDate = 0, healed = 0;
613
+ // Subscribed on the project row but absent from this roster: there is no `dir` to upgrade, so say
614
+ // so and count it a skip. Silence here is how a restored backup reported failed=0 on a dead sweep.
615
+ for (const slug of only ? [] : merged.unreachable) {
616
+ console.error(` ! ${slug}: subscribed on its project row, but this roster has no entry with a dir for it — not upgraded`);
617
+ skipped++;
618
+ }
606
619
  const refusalsFile = refusalsPath();
607
620
  const refusals = readRefusals(refusalsFile);
608
621
 
@@ -817,6 +830,7 @@ function main({ instanceRoot: defaultRoot = process.cwd() } = {}) {
817
830
  upgraded++;
818
831
  }
819
832
 
833
+ if (!only && roster.length === 0 && skipped === 0) console.log('\ncore-update-subscription: NOTHING SUBSCRIBED — no instance was considered, so no upgrade was attempted (this is not a clean run).');
820
834
  console.log(`\ncore-update-subscription: upgraded=${upgraded} up-to-date=${upToDate} skipped=${skipped} failed=${failed}${healed ? ` healed=${healed}` : ''} (${mode})`);
821
835
  return failed > 0 ? 1 : 0;
822
836
  }
@@ -89,6 +89,8 @@ const OVERRIDES = Object.freeze({
89
89
  bfg_write_disabled: 'forbidden',
90
90
  target_is_system_principal: 'forbidden',
91
91
  cannot_change_own_tester: 'forbidden',
92
+ cannot_disable_own_card_requirement: 'forbidden',
93
+ no_card_registered: 'conflict',
92
94
  self_vote_forbidden: 'forbidden',
93
95
  self_dep: 'conflict',
94
96
  newcomer_must_ship_first: 'forbidden',
package/src/bongos/db.js CHANGED
@@ -724,6 +724,48 @@ async function setBuilderTester({ builderId, isTester }) {
724
724
  });
725
725
  }
726
726
 
727
+ // setRequireCard — an Archon switches the hardware-card requirement on or off for one
728
+ // builder (task 1004748, ADR 0382 D3/D4). Switching ON needs at least one registered
729
+ // card, so nobody is locked out by the switch. The audit row is written in the SAME
730
+ // transaction as the flag, so a change cannot happen without its entry; unlike the
731
+ // generic request log it names the builder it was done TO.
732
+ async function setRequireCard({ builderId, required, actorBuilderId, ip = null, userAgent = null }) {
733
+ return withTx(async (client) => {
734
+ const { rows: found } = await client.query(
735
+ `SELECT id, system_role, require_card FROM builders WHERE id = $1 FOR UPDATE`,
736
+ [builderId]
737
+ );
738
+ const before = found[0];
739
+ if (!before) return null;
740
+ if (before.system_role) throw { code: 'TARGET_IS_SYSTEM_PRINCIPAL' };
741
+ if (required) {
742
+ const { rows: cards } = await client.query(
743
+ `SELECT 1 FROM passkey_credentials WHERE builder_id = $1 LIMIT 1 FOR SHARE`,
744
+ [builderId]
745
+ );
746
+ if (!cards[0]) throw { code: 'NO_CARD_REGISTERED' };
747
+ }
748
+ const { rows } = await client.query(
749
+ `UPDATE builders SET require_card = $2 WHERE id = $1
750
+ RETURNING id, github_login, display_name, rank, status, require_card`,
751
+ [builderId, !!required]
752
+ );
753
+ await client.query(
754
+ `INSERT INTO audit_log
755
+ (builder_id, route, method, request_body_redacted, response_status, ip, user_agent, surface)
756
+ VALUES ($1, $2, 'PATCH', $3, 200, $4, $5, NULL)`,
757
+ [
758
+ actorBuilderId,
759
+ `${require('./api-prefix').API_PREFIX}/builders/:id/card-requirement`,
760
+ { target_builder_id: String(builderId), require_card: !!required, was: before.require_card === true },
761
+ ip,
762
+ userAgent,
763
+ ]
764
+ );
765
+ return rows[0];
766
+ });
767
+ }
768
+
727
769
  async function reactivateBuilder({ builderId, actorBuilderId }) {
728
770
  return withTx(async (client) => {
729
771
  const { rows } = await client.query(
@@ -1183,6 +1225,7 @@ module.exports = {
1183
1225
  RANKS,
1184
1226
  setBuilderRank,
1185
1227
  setBuilderTester,
1228
+ setRequireCard,
1186
1229
  getLatestRankChangeForBuilder,
1187
1230
  deactivateBuilder,
1188
1231
  reactivateBuilder,
@@ -194,6 +194,7 @@ const EXPECTED_RANKS = {
194
194
  // builder authority — rank + lifecycle status mutation
195
195
  'PATCH /builders/:id/rank': 'perm:builder.rank.set',
196
196
  'PATCH /builders/:id/status': 'perm:builder.status.set',
197
+ 'PATCH /builders/:id/card-requirement': 'perm:builder.rank.set',
197
198
  // task 1004561: the Treasury desk sets a builder's monthly limit through it
198
199
  'PATCH /builders/:id/budget': 'perm:builder.budget.set',
199
200
  // session revocation (kill another builder's / all sessions)
@@ -421,6 +421,43 @@ module.exports = function buildBuildersRouter() {
421
421
  }
422
422
  });
423
423
 
424
+ // PATCH /builders/:id/card-requirement — an Archon switches the hardware-card
425
+ // requirement on or off for one builder (task 1004748, ADR 0382 D4). Body:
426
+ // { require_card: boolean }. Default is off for everyone. Switching ON refuses 409
427
+ // no_card_registered unless the builder has a registered card, so the switch cannot
428
+ // lock anyone out. You cannot switch OFF your own requirement (403
429
+ // cannot_disable_own_card_requirement) — a stolen session must not be able to drop
430
+ // it; another Archon does it. Every change writes an audit row naming the target.
431
+ // rank: archon. Gate: requirePermission('builder.rank.set') (the non-delegable atom that
432
+ // guards the step-up's own target route) then requireCardStepUp; read fresh per request.
433
+ router.patch('/builders/:id/card-requirement', auth.requireBuilder, auth.requirePermission('builder.rank.set'), requireCardStepUp, async (req, res) => {
434
+ if (validateOrRespond(req, res, { require_card: { required: true, type: 'boolean' } })) return;
435
+ const targetId = parseId(req, res, { code: 'bad_builder_id', positiveInt: true });
436
+ if (targetId === null) return;
437
+ const required = req.body.require_card;
438
+ if (!required && String(targetId) === String(req.builder.id)) {
439
+ return res.fail('cannot_disable_own_card_requirement', 403);
440
+ }
441
+ try {
442
+ const builder = await db.setRequireCard({
443
+ builderId: targetId,
444
+ required,
445
+ actorBuilderId: req.builder.id,
446
+ ip: req.ip || null,
447
+ userAgent: req.headers['user-agent'] || null,
448
+ });
449
+ if (!builder) return res.fail('builder_not_found', 404);
450
+ res.json({ builder });
451
+ } catch (err) {
452
+ if (err && err.code === 'TARGET_IS_SYSTEM_PRINCIPAL') return res.fail('target_is_system_principal', 403);
453
+ if (err && err.code === 'NO_CARD_REGISTERED') {
454
+ return res.fail('no_card_registered', 409, { message: 'That builder has no registered card yet; they must register one before the requirement can be switched on.' });
455
+ }
456
+ log.error('[gds] PATCH /builders/:id/card-requirement', err);
457
+ res.fail('card_requirement_change_failed', { status: 500, message: 'internal error' });
458
+ }
459
+ });
460
+
424
461
  // PATCH /builders/:id/capabilities — the owner gives or takes a builder's server
425
462
  // access (task 1004950, ADR 0395). Body: { server: boolean }. A task that needs
426
463
  // the server is shown to and claimable by a builder only while this is on.
package/src/branding.js CHANGED
@@ -94,6 +94,9 @@ const ENV_OVERRIDES = [
94
94
  ['NIGHT_TINT', ['project', 'nightTint']],
95
95
  ['LOOK_ACCENT', ['project', 'lookAccent']],
96
96
  ['LOGO_URL', ['project', 'logoUrl']],
97
+ // 'on' only on a TEST project (task 1005036, modules/provisioning/test-project.js): the hall labels itself
98
+ // and the not-yet-live feature switches open. Empty on every other project.
99
+ ['TEST_PROJECT', ['project', 'test']],
97
100
  // The birth marker (task 1004506): set by the platform only on a project created
98
101
  // through the startup flow, so a project made before that never has it (spec D8).
99
102
  // FOUNDING_BIRTH is when the platform made the row; DEMO_PEOPLE / DEMO_HOURS are the
@@ -258,6 +261,10 @@ function clientBranding(b = branding()) {
258
261
  mark: b.identity?.company?.mark || '',
259
262
  },
260
263
  },
264
+ // A TEST project (task 1005036, modules/provisioning/test-project.js): the hall labels itself and the
265
+ // not-yet-live feature switches open. Beside `project`, not in it: that block is the settings the
266
+ // platform pushes, key for key, and this is a mark, not a setting.
267
+ testProject: b.project?.test === 'on',
261
268
  currency: {
262
269
  label: b.currency?.label || 'credits',
263
270
  symbol: b.currency?.symbol || '',
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.25.4'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,75 @@
1
+ // tests/builder_card_requirement.mjs
2
+ //
3
+ // Task 1004748 (ADR 0382 D3/D4) — an Archon switches the hardware-card requirement on
4
+ // per builder. DB-free: the shared pool's client is a recorder.
5
+ //
6
+ // Run: node tests/builder_card_requirement.mjs
7
+
8
+ import { strict as assert } from 'node:assert';
9
+ import { createRequire } from 'node:module';
10
+ import { makeRunner } from './helpers.mjs';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const { pool } = require('../src/bongos/pool');
14
+ const { test, summary } = makeRunner();
15
+
16
+ let builderRow; let hasCard; const sent = [];
17
+ pool.connect = async () => ({
18
+ query: async (sql, params = []) => {
19
+ const q = String(sql);
20
+ sent.push({ sql: q, params });
21
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(q)) return { rows: [] };
22
+ if (/FROM builders WHERE id/.test(q)) return { rows: builderRow ? [builderRow] : [] };
23
+ if (/FROM passkey_credentials/.test(q)) return { rows: hasCard ? [{}] : [] };
24
+ if (/UPDATE builders SET require_card/.test(q)) return { rows: [{ id: params[0], require_card: params[1] }] };
25
+ return { rows: [] };
26
+ },
27
+ release() {},
28
+ });
29
+
30
+ const { setRequireCard } = require('../src/bongos/db');
31
+ const reset = () => { sent.length = 0; };
32
+
33
+ await test('switching on refuses a builder with no registered card, writes nothing', async () => {
34
+ reset(); builderRow = { id: '7', system_role: null, require_card: false }; hasCard = false;
35
+ await assert.rejects(setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' }), { code: 'NO_CARD_REGISTERED' });
36
+ assert.equal(sent.some((s) => /UPDATE builders|INSERT INTO audit_log/.test(s.sql)), false);
37
+ });
38
+
39
+ await test('switching on with a card sets the flag and audits the target in the same transaction', async () => {
40
+ reset(); hasCard = true;
41
+ const r = await setRequireCard({ builderId: '7', required: true, actorBuilderId: '1' });
42
+ assert.equal(r.require_card, true);
43
+ const audit = sent.find((s) => /INSERT INTO audit_log/.test(s.sql));
44
+ assert.ok(audit);
45
+ assert.equal(audit.params[0], '1');
46
+ assert.deepEqual(audit.params[2], { target_builder_id: '7', require_card: true, was: false });
47
+ });
48
+
49
+ await test('switching off needs no card and is audited', async () => {
50
+ reset(); hasCard = false; builderRow = { id: '7', system_role: null, require_card: true };
51
+ const r = await setRequireCard({ builderId: '7', required: false, actorBuilderId: '1' });
52
+ assert.equal(r.require_card, false);
53
+ assert.deepEqual(sent.find((s) => /INSERT INTO audit_log/.test(s.sql)).params[2].was, true);
54
+ });
55
+
56
+ await test('unknown builder is null; a system principal is refused', async () => {
57
+ reset(); builderRow = null;
58
+ assert.equal(await setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), null);
59
+ builderRow = { id: '9', system_role: 'runner', require_card: false };
60
+ await assert.rejects(setRequireCard({ builderId: '9', required: false, actorBuilderId: '1' }), { code: 'TARGET_IS_SYSTEM_PRINCIPAL' });
61
+ });
62
+
63
+ await test('route: rank atom + step-up gate, self-disable refused, 409 mapped, errors classified', async () => {
64
+ const fs = await import('node:fs');
65
+ const src = fs.readFileSync(new URL('../src/bongos/routes/builders.js', import.meta.url), 'utf8');
66
+ const line = src.split(/\r?\n/).find((l) => l.includes("router.patch('/builders/:id/card-requirement'"));
67
+ assert.match(line, /requirePermission\('builder\.rank\.set'\), requireCardStepUp/);
68
+ assert.match(src, /cannot_disable_own_card_requirement', 403/);
69
+ assert.match(src, /NO_CARD_REGISTERED[\s\S]{0,80}no_card_registered', 409/);
70
+ const { classify } = require('../src/bongos/api-errors');
71
+ assert.equal(classify('no_card_registered'), 'conflict');
72
+ assert.equal(classify('cannot_disable_own_card_requirement'), 'forbidden');
73
+ });
74
+
75
+ summary();
@@ -84,7 +84,9 @@ test('clientBranding is a SMALL allow-list — no server-only field leaks to the
84
84
  // had been failing since that landed (task 1002470).
85
85
  // `project` joined the allow-list in task 1003140: the per-project policy is public
86
86
  // by nature and is the settings read-back a manage page needs (see clientBranding).
87
- assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'theme']);
87
+ assert.deepEqual(Object.keys(projected).sort(), ['copy', 'currency', 'identity', 'project', 'rankLabels', 'reward', 'testProject', 'theme']);
88
+ // `testProject` joined in task 1005036: the platform's test-project mark ('on' or
89
+ // not), public by nature: the hall labels itself and opens its not-yet-live switches.
88
90
  // `artistGate` joined in task 1003575 (ADR 0241) — the project's artist strictness,
89
91
  // public for the same read-back reason as its neighbours. Its companion stamp
90
92
  // (artistGateSince) deliberately does NOT: that is platform bookkeeping the server