@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.
- package/.bongos-core.json +77 -47
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +12 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/api/openapi.json +239 -3
- package/docs/api-reference.md +8 -5
- package/docs/copy-inventory.md +12 -12
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +49 -52
- package/modules/hall-ui/public/shell-game.js +34 -3
- package/modules/hall-ui/public/welcome-boot.js +27 -11
- package/modules/hall-ui/records/start-screen.md +10 -0
- package/modules/onboarding/genesis-home.js +35 -1
- package/modules/onboarding/routes/onboarding.js +7 -2
- package/modules/provisioning/catalog-bridge.js +3 -0
- package/modules/provisioning/migrations/provisioning_037_update_subscribed.sql +21 -0
- package/modules/provisioning/module.json +1 -0
- package/modules/provisioning/paid-shape-gate.js +3 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/provisioning.js +11 -4
- package/modules/provisioning/routes/update-subscription.js +51 -0
- package/modules/provisioning/test-project.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +20 -0
- package/scripts/bongos/government-walk.js +2 -2
- package/scripts/bongos/update-channel.js +27 -4
- package/scripts/bongos/update-sweep.js +17 -3
- package/src/bongos/api-errors.js +2 -0
- package/src/bongos/db.js +43 -0
- package/src/bongos/route-rank-check.js +1 -0
- package/src/bongos/routes/builders.js +37 -0
- package/src/branding.js +7 -0
- package/src/module-api.js +1 -1
- package/tests/builder_card_requirement.mjs +75 -0
- package/tests/currency_label.mjs +3 -1
- package/tests/hall_start_screen.mjs +62 -8
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_paid_shape_gate.mjs +16 -0
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_update_subscription_route.mjs +141 -0
- package/tests/test_project.mjs +122 -0
- package/tests/update_channel_db.mjs +24 -0
- package/tests/wizard_demo.mjs +25 -4
|
@@ -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
|
-
|
|
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
|
|
477
|
-
if (created && (demoAsk.demo ||
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
597
|
-
if (db && !db.ok &&
|
|
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)
|
|
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
|
}
|
package/src/bongos/api-errors.js
CHANGED
|
@@ -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.
|
|
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();
|
package/tests/currency_label.mjs
CHANGED
|
@@ -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
|