@bongos/core 1.25.5 → 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 +49 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -2
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +12 -12
- package/docs/copy-registry.json +13 -13
- package/docs/module-api-changelog.md +2 -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/paid-shape-gate.js +3 -1
- package/modules/provisioning/provisioning.js +3 -3
- package/modules/provisioning/routes/provisioning.js +7 -4
- package/modules/provisioning/test-project.js +65 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/scripts/bongos/government-walk.js +2 -2
- package/src/branding.js +7 -0
- package/src/module-api.js +1 -1
- 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/test_project.mjs +122 -0
- package/tests/wizard_demo.mjs +25 -4
|
@@ -29,3 +29,13 @@ Tests: `tests/hall_start_screen.mjs` (the hand-off and pure parts in a vm; both
|
|
|
29
29
|
Every sign-in now opens on a chooser before the start screen: **Game** or **Classic**, each with one line saying what it is. The view chosen last time (the browser's `hall-game` copy) is marked "Last time", lit with the accent on its edge, and focused, so Enter keeps it. Game goes on to the start screen as before; Classic closes the screen and the hall is as it always was. Either choice is saved to the account (`PATCH /api/bongos/me/hall-view`). The Extras list lost The docs: it is not a game place. Tests: `tests/hall_start_screen.mjs`.
|
|
30
30
|
|
|
31
31
|
**In the start menu's shape (task 1005206, owner 2026-11-11, from the V3 Helm start-screen mock-up).** The chooser is drawn like the menu it leads to: "How do you want to see it?" is a small label in the name voice, and the view Enter takes (last time's, else Game) is the accent bar with its "Enter" key, exactly as Start is (`.gs__item--lit` shares Start's rules); the other view is a quiet row on a hairline. Each row keeps its one line; on the lit bar the line and the "Last time" tag take `--accent-on`. Focus starts on the lit row.
|
|
32
|
+
|
|
33
|
+
## Land on the chooser (task 1005219, owner 2026-11-12)
|
|
34
|
+
|
|
35
|
+
The hall's front (Home, `/builders`) now opens on the Game / Classic chooser **every time the site is opened**, signed in or not: one `sessionStorage` mark per tab (`hall-chooser-seen`), so moving about the hall — game mode sends a classic page Home — never asks twice. This supersedes "a new visit keeps the welcome" and "a signed-out visitor keeps the welcome" above; the painted welcome remains only for a hall without `shell-game.js` and for the day's first visit to a page other than Home.
|
|
36
|
+
|
|
37
|
+
- **Signed out:** the pick is kept in `localStorage` (`hall-view-pending`) and the visitor goes to GitHub sign-in (`/api/bongos/auth/web/start`, which returns by the referer). On the return `start()` spends the pick: Game opens straight on the start screen's menu, Classic shows no screen and the hall is as it always was. Either is saved to the account then, as at the chooser.
|
|
38
|
+
- **Signed in:** the pick goes straight to that menu, as at sign-in.
|
|
39
|
+
- The marketing page at the apex root is unchanged (the owner's pick: the hall's front, not the apex).
|
|
40
|
+
|
|
41
|
+
Tests: `tests/hall_start_screen.mjs` (the vm hand-off, and the signed-out round trip for both picks in a real browser).
|
|
@@ -276,6 +276,40 @@ async function startFounding(builderId, deps = {}) {
|
|
|
276
276
|
return { ok: true, mode };
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// isTestProject(deps) — is this hall a TEST project? The platform marks one (task 1005036,
|
|
280
|
+
// modules/provisioning/test-project.js) and the mark arrives as branding project.test. Genesis is paused
|
|
281
|
+
// for every other project (owner, 2026-11-01), so only a test project may switch it on by hand.
|
|
282
|
+
function isTestProject(deps = {}) {
|
|
283
|
+
try {
|
|
284
|
+
const b = deps.branding !== undefined ? deps.branding : require('../../src/module-api').branding();
|
|
285
|
+
return !!(b && b.project && b.project.test === 'on');
|
|
286
|
+
} catch (_) { return false; }
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ownerStart(builderId, deps) — the owner's own start (POST /founding/start): a test project only.
|
|
290
|
+
async function ownerStart(builderId, deps = {}) {
|
|
291
|
+
if (!isTestProject(deps)) return { ok: false, reason: 'not_a_test_project' };
|
|
292
|
+
return startFounding(builderId, deps);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// setEnabled(enabled, builderId, deps) — the Test features panel's genesis switch (task 1005036): a test
|
|
296
|
+
// project only. On starts genesis if it never started (startFounding turns the switch on); if it already
|
|
297
|
+
// began, it just turns the switch back on. Off pauses it — the founding record is kept, the hall opens full.
|
|
298
|
+
async function setEnabled(enabled, builderId, deps = {}) {
|
|
299
|
+
if (!isTestProject(deps)) return { ok: false, reason: 'not_a_test_project' };
|
|
300
|
+
const s = seams(deps);
|
|
301
|
+
if (enabled === true) {
|
|
302
|
+
const started = await s.get(fm.KEYS.STARTED_AT);
|
|
303
|
+
if (!(started && started.value)) return startFounding(builderId, deps);
|
|
304
|
+
const ended = await s.get(fm.KEYS.ENDED_AT);
|
|
305
|
+
if (ended && ended.value) return { ok: false, reason: 'already_alive' };
|
|
306
|
+
await s.set(fm.KEYS.ENABLED, 'true', builderId);
|
|
307
|
+
} else {
|
|
308
|
+
await s.set(fm.KEYS.ENABLED, 'false', builderId);
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, enabled: enabled === true, mode: await fm.foundingModeFor(modeDeps(s)) };
|
|
311
|
+
}
|
|
312
|
+
|
|
279
313
|
// savePlan — the decide stage's door: record the plan, file the five stages as tasks,
|
|
280
314
|
// and move to scope. Only while deciding.
|
|
281
315
|
async function savePlan(body, builderId, deps = {}) {
|
|
@@ -508,7 +542,7 @@ async function onTaskShipped(deps = {}) {
|
|
|
508
542
|
|
|
509
543
|
module.exports = {
|
|
510
544
|
PLAN_KEY, STAGE_INFO, PROPER, DEMO_PEOPLE, DEMO_HOURS, intIn, parsePlan, shapeGenesis, demoMinutes, demoPlan,
|
|
511
|
-
readGenesis, startFounding, savePlan, applyGovernmentChoice, closeStage, comeAlive, skipGenesis, onStagePassed,
|
|
545
|
+
readGenesis, startFounding, ownerStart, setEnabled, savePlan, applyGovernmentChoice, closeStage, comeAlive, skipGenesis, onStagePassed,
|
|
512
546
|
putFinishedStages, onTaskShipped,
|
|
513
547
|
// the founding-mode deps this file reads the mode with, for founding-birth.js (task 1004506)
|
|
514
548
|
modeDepsFor: (deps) => modeDeps(seams(deps)),
|
|
@@ -124,7 +124,7 @@ const GENESIS_REFUSALS = {
|
|
|
124
124
|
not_at_legislation: 409, legislation_not_passed: 409, stage_tasks_unfinished: 409,
|
|
125
125
|
no_stage_tasks: 409, unknown_stage: 400, no_board: 503,
|
|
126
126
|
bad_days: 400, bad_passes: 400, bad_advisors: 400, bad_government: 400, bad_expected_builders: 400,
|
|
127
|
-
bad_demo: 400, bad_minutes: 400,
|
|
127
|
+
bad_demo: 400, bad_minutes: 400, not_a_test_project: 403,
|
|
128
128
|
};
|
|
129
129
|
let genesisListening = false;
|
|
130
130
|
|
|
@@ -158,7 +158,12 @@ function mountGenesisRoutes(router) {
|
|
|
158
158
|
await foundingBirth.ensureBirth();
|
|
159
159
|
return { genesis: await genesis.readGenesis(), viewer_id: String(req.builder.id) };
|
|
160
160
|
}));
|
|
161
|
-
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.
|
|
161
|
+
router.post('/founding/start', api.requireBuilder, owns, handle((req) => genesis.ownerStart(req.builder.id)));
|
|
162
|
+
// The Test features panel's genesis switch (task 1005036): a test project only, the owner's call.
|
|
163
|
+
router.put('/founding/enabled', api.requireBuilder, owns, (req, res, next) => {
|
|
164
|
+
if (api.validateOrRespond(req, res, { enabled: { type: 'boolean' } })) return;
|
|
165
|
+
next();
|
|
166
|
+
}, handle((req) => genesis.setEnabled(req.body && req.body.enabled === true, req.builder.id)));
|
|
162
167
|
// The plan's ranges and vocabularies are genesis-home.js parsePlan's (one source);
|
|
163
168
|
// this schema only names the fields so an unknown one is refused.
|
|
164
169
|
router.post('/founding/plan', api.requireBuilder, owns, (req, res, next) => {
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const api = require('../../src/module-api');
|
|
21
21
|
const { effectiveSettings } = require('./provisioning');
|
|
22
|
+
const { isTestProject } = require('./test-project');
|
|
22
23
|
|
|
23
24
|
const log = api.logger('provisioning');
|
|
24
25
|
|
|
@@ -57,6 +58,8 @@ function catalogStatusForInstance(row) {
|
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function catalogEntryForInstance(row, { ownerGithubId } = {}) {
|
|
61
|
+
// A test project is never on the public map (task 1005036, ./test-project.js): the honest refusal, not a failure.
|
|
62
|
+
if (isTestProject(row)) return null;
|
|
60
63
|
const origin = catalogOriginForInstance(row);
|
|
61
64
|
if (!origin) return null;
|
|
62
65
|
const owner = Number(ownerGithubId);
|
|
@@ -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)
|
|
@@ -361,7 +361,7 @@ module.exports = function provisioningRoutes() {
|
|
|
361
361
|
// exactly the set the panel preselected. An explicit [] is the different
|
|
362
362
|
// answer "none of them". maxItems is a sanity ceiling well above a catalog
|
|
363
363
|
// that is three modules wide today.
|
|
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' }, // 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)
|
|
365
365
|
})) return;
|
|
366
366
|
const body = req.body || {};
|
|
367
367
|
const detailFault = badProjectDetail(body.detail);
|
|
@@ -372,6 +372,8 @@ module.exports = function provisioningRoutes() {
|
|
|
372
372
|
if (screened) return res.fail('screened_out', screened);
|
|
373
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)
|
|
374
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 });
|
|
375
377
|
const slug = String(body.slug || '').trim().toLowerCase();
|
|
376
378
|
if (!provisioning.isValidSlug(slug)) {
|
|
377
379
|
return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
|
|
@@ -477,8 +479,9 @@ module.exports = function provisioningRoutes() {
|
|
|
477
479
|
// provision intent exists, so the runner's web.env already carries the marker and the demo's box.
|
|
478
480
|
// Best-effort: a project that misses its marker opens as an ordinary hall; the create still stands.
|
|
479
481
|
// Genesis is OFF by default (owner, 2026-11-01): a new project opens as the full hall. Only a demo (its
|
|
480
|
-
// time box lives on the birth marker) or
|
|
481
|
-
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));
|
|
482
485
|
// The wizard's ship mode (task 1004629) rides beside it; a miss means the project asks at its first ship.
|
|
483
486
|
if (created) await birth.stampShipMode(pool, instance.id, body.ship_mode).catch((err) => log.error('[provisioning] ship mode not stamped', err));
|
|
484
487
|
const demoRow = created && demoAsk.demo ? await demo.startDemoProject(pool, provisioning, instance.id, demoAsk.demo) : null;
|
|
@@ -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
|
@@ -11728,5 +11728,15 @@
|
|
|
11728
11728
|
"id": "1004150",
|
|
11729
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
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
|
+
}
|
|
11731
11741
|
]
|
|
11732
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');
|
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');
|
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
|
|
@@ -23,17 +23,20 @@ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
|
|
|
23
23
|
const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
|
|
24
24
|
|
|
25
25
|
// ---------- in a vm: the pure parts and the welcome's hand-off ----------
|
|
26
|
-
function sandbox({ store = {}, game = true } = {}) {
|
|
27
|
-
const started = [], asked = [];
|
|
26
|
+
function sandbox({ store = {}, session = {}, game = true } = {}) {
|
|
27
|
+
const started = [], asked = [], went = [];
|
|
28
28
|
const window = {
|
|
29
|
-
location: { search: '', pathname: '/', hash: '' },
|
|
29
|
+
location: { search: '', pathname: '/', hash: '', assign: (h) => went.push(h) },
|
|
30
30
|
__BRANDING__: { identity: { worldName: 'Harbor' } },
|
|
31
31
|
localStorage: {
|
|
32
32
|
getItem: (k) => (k in store ? store[k] : null),
|
|
33
33
|
setItem: (k, v) => { store[k] = String(v); },
|
|
34
34
|
removeItem: (k) => { delete store[k]; },
|
|
35
35
|
},
|
|
36
|
-
sessionStorage: {
|
|
36
|
+
sessionStorage: {
|
|
37
|
+
getItem: (k) => (k in session ? session[k] : null),
|
|
38
|
+
setItem: (k, v) => { session[k] = String(v); },
|
|
39
|
+
},
|
|
37
40
|
OTBCraftMode: { markAsked: () => asked.push('marked'), onAccess: (a, b) => asked.push(['picker', a.authed, b.id]) },
|
|
38
41
|
};
|
|
39
42
|
const document = { documentElement: { dataset: {}, classList: { add() {}, remove() {} } }, addEventListener() {} };
|
|
@@ -44,7 +47,7 @@ function sandbox({ store = {}, game = true } = {}) {
|
|
|
44
47
|
window.OTBShellGame.start = (o) => { started.push(o); return true; };
|
|
45
48
|
}
|
|
46
49
|
vm.runInContext(read('welcome-boot.js'), box, { filename: 'welcome-boot.js' });
|
|
47
|
-
return { window, store, started, asked };
|
|
50
|
+
return { window, store, session, started, asked, went };
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
test('the menu is Start, Options, Achievements, Extras, Quit, in that order', () => {
|
|
@@ -105,10 +108,27 @@ test('a fresh sign-in is handed to the start screen with the still, and the craf
|
|
|
105
108
|
assert.deepEqual(s.asked, [['picker', true, 7]], 'the picker is asked once the world is showing');
|
|
106
109
|
});
|
|
107
110
|
|
|
108
|
-
test('
|
|
109
|
-
const
|
|
111
|
+
test('every visit to Home opens on Game or Classic, signed in or not (task 1005219)', () => {
|
|
112
|
+
const visit = sandbox();
|
|
113
|
+
assert.equal(visit.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
114
|
+
assert.deepEqual([visit.started.length, visit.asked], [1, []], 'a new visit, signed in, is the chooser, not the welcome');
|
|
115
|
+
const deep = sandbox();
|
|
116
|
+
deep.window.location.pathname = '/goals';
|
|
117
|
+
assert.equal(deep.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
118
|
+
assert.equal(deep.started.length, 0, 'only Home asks; a page deeper in keeps the day\'s welcome');
|
|
119
|
+
const out = sandbox();
|
|
120
|
+
assert.equal(out.window.OTBWelcome.onAccess({ authed: false }, null), true);
|
|
121
|
+
assert.equal(out.started.length, 1, 'signed out, the chooser stands in for the painted welcome');
|
|
122
|
+
assert.equal(typeof out.started[0].signIn, 'function', 'and a pick goes on to sign in');
|
|
123
|
+
out.started[0].signIn();
|
|
124
|
+
assert.deepEqual([out.went, out.store['hall-welcome-signin']], [['/api/bongos/auth/web/start'], '1'],
|
|
125
|
+
'to GitHub, remembered as a sign-in so the return opens the picked menu');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('within one visit, Home asks once; the day\'s welcome stays for a later page, and a hall without shell-game.js greets a sign-in as before', () => {
|
|
129
|
+
const daily = sandbox({ session: { 'hall-chooser-seen': '1' } });
|
|
110
130
|
assert.equal(daily.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
111
|
-
assert.deepEqual([daily.started.length, daily.asked], [0, ['marked']], 'the day\'s first visit is the welcome\'s');
|
|
131
|
+
assert.deepEqual([daily.started.length, daily.asked], [0, ['marked']], 'asked already this visit: the day\'s first visit is the welcome\'s');
|
|
112
132
|
const bare = sandbox({ store: { 'hall-welcome-signin': '1' }, game: false });
|
|
113
133
|
assert.equal(bare.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
|
|
114
134
|
assert.deepEqual(bare.asked, ['marked']);
|
|
@@ -286,6 +306,40 @@ test('choosing Classic at sign-in closes the screen, leaves game mode off, and s
|
|
|
286
306
|
await ctx.close();
|
|
287
307
|
});
|
|
288
308
|
|
|
309
|
+
// task 1005219: the hall's front opens on the chooser signed out; the pick goes to sign-in and the
|
|
310
|
+
// return opens that menu without asking again — the start screen for Game, the plain hall for Classic.
|
|
311
|
+
test('signed out, a pick goes to sign-in, and the return opens the picked menu without asking again', { skip }, async () => {
|
|
312
|
+
for (const pick of ['game', 'classic']) {
|
|
313
|
+
const ctx = await hall();
|
|
314
|
+
await ctx.route(ORIGIN + '/api/bongos/auth/web/start', (route) => route.fulfill({ contentType: 'text/html', body: 'github' }));
|
|
315
|
+
const page = await ctx.newPage();
|
|
316
|
+
await page.goto(ORIGIN + '/');
|
|
317
|
+
await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
|
|
318
|
+
await page.evaluate(() => window.OTBShell.applyAccess({ authed: false }));
|
|
319
|
+
await page.waitForSelector('#game-start');
|
|
320
|
+
assert.equal((await state(page)).welcome, false, 'the chooser, not the painted welcome');
|
|
321
|
+
await Promise.all([page.waitForURL(ORIGIN + '/api/bongos/auth/web/start'), page.click(`[data-gs="view-${pick}"]`)]);
|
|
322
|
+
assert.deepEqual(await page.evaluate(() => [localStorage.getItem('hall-view-pending'), localStorage.getItem('hall-welcome-signin')]), [pick, '1']);
|
|
323
|
+
|
|
324
|
+
// back from GitHub, signed in
|
|
325
|
+
await page.goto(ORIGIN + '/');
|
|
326
|
+
await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
|
|
327
|
+
await page.evaluate(() => { const me = { authed: true, rank: 'metic', builder: { id: 7, display_name: 'Ada', github_login: 'ada' } }; window.OTBShell.applyAccess(me); window.OTBShell.applyAccess(me); });
|
|
328
|
+
await page.evaluate(() => new Promise((r) => setTimeout(r, 100)));
|
|
329
|
+
const s = await state(page);
|
|
330
|
+
if (pick === 'game') {
|
|
331
|
+
assert.equal(s.start, true, 'Game: the start screen');
|
|
332
|
+
assert.equal(await page.isVisible('#gs-items'), true, 'on its menu');
|
|
333
|
+
assert.equal(await page.isVisible('#gs-views'), false, 'not asked again');
|
|
334
|
+
assert.equal(s.game, 'on');
|
|
335
|
+
} else {
|
|
336
|
+
assert.deepEqual([s.start, s.welcome, s.game], [false, false, null], 'Classic: the hall, no screen, game mode off');
|
|
337
|
+
}
|
|
338
|
+
assert.equal(await page.evaluate(() => localStorage.getItem('hall-view-pending')), null, 'the pick is spent');
|
|
339
|
+
await ctx.close();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
289
343
|
test('on a phone the panel is the whole width, nothing scrolls sideways and every choice clears the floor (task 1005192)', { skip }, async () => {
|
|
290
344
|
for (const [w, h] of [[390, 844], [320, 700]]) {
|
|
291
345
|
const ctx = await hall({ viewport: { width: w, height: h } });
|
|
@@ -80,6 +80,7 @@ ${NEUTRAL_PREFIX}_FOUNDING_GENESIS=
|
|
|
80
80
|
${NEUTRAL_PREFIX}_DEMO_PEOPLE=
|
|
81
81
|
${NEUTRAL_PREFIX}_DEMO_HOURS=
|
|
82
82
|
${NEUTRAL_PREFIX}_SHIP_MODE=
|
|
83
|
+
${NEUTRAL_PREFIX}_TEST_PROJECT=
|
|
83
84
|
${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
|
|
84
85
|
`;
|
|
85
86
|
|
|
@@ -151,7 +152,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
|
|
|
151
152
|
// the birth marker and a demo's box (task 1004506), empty on an un-stamped row
|
|
152
153
|
[`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
|
|
153
154
|
// the wizard's ship mode (task 1004629), empty when none was picked
|
|
154
|
-
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '',
|
|
155
|
+
[`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '',
|
|
155
156
|
// where the hall's Update button goes (task 1004608), empty when no hub origin resolves
|
|
156
157
|
[`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
|
|
157
158
|
});
|
|
@@ -243,6 +243,22 @@ await ta('cloud-host: ADMITTED past the limit for a builder holding provisioning
|
|
|
243
243
|
assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
|
|
244
244
|
});
|
|
245
245
|
|
|
246
|
+
await ta('cloud-host: a TEST project holds no free place, so it does not make the next one a second (task 1005036)', async () => {
|
|
247
|
+
// Only an archon makes a test project, and it is for trying features: counting it would
|
|
248
|
+
// charge a builder's one free place for a project nobody meant to keep.
|
|
249
|
+
held = [];
|
|
250
|
+
owned = [{ ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
|
|
251
|
+
lastCreateArgs = null;
|
|
252
|
+
const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
253
|
+
assert.equal(res.status, 201, 'a test project alone leaves the free place free: ' + res.raw);
|
|
254
|
+
held = [];
|
|
255
|
+
owned = [LIVE_HOSTED, { ...LIVE_HOSTED, slug: 'sandbox', settings: { test_project: 'on' } }];
|
|
256
|
+
lastCreateArgs = null;
|
|
257
|
+
const second = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
|
|
258
|
+
owned = [];
|
|
259
|
+
assert.equal(second.status, 403, 'a real project plus a test one still counts the real one');
|
|
260
|
+
});
|
|
261
|
+
|
|
246
262
|
// ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
|
|
247
263
|
|
|
248
264
|
await ta('control-plane: refused for an unprivileged builder, and the refusal is not a permission', async () => {
|
|
@@ -128,7 +128,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
128
128
|
// route never stamped, which is every row made before it shipped (spec D8).
|
|
129
129
|
CLOUDBONGOS_FOUNDING_BIRTH: '', CLOUDBONGOS_FOUNDING_GENESIS: '', CLOUDBONGOS_DEMO_PEOPLE: '', CLOUDBONGOS_DEMO_HOURS: '',
|
|
130
130
|
// The wizard's ship mode (task 1004629): empty when none was picked.
|
|
131
|
-
CLOUDBONGOS_SHIP_MODE: '',
|
|
131
|
+
CLOUDBONGOS_SHIP_MODE: '', CLOUDBONGOS_TEST_PROJECT: '',
|
|
132
132
|
});
|
|
133
133
|
// varsOf splits on '=', so an empty value reads as '' on both sides — the point
|
|
134
134
|
// of the comparison is that the standup lines carry exactly the same key set.
|
|
@@ -137,7 +137,7 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
|
|
|
137
137
|
X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
|
|
138
138
|
X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
|
|
139
139
|
X_LOOK: 'own', X_NIGHT_TINT: 'on', X_LOOK_ACCENT: '', X_LOGO_URL: '',
|
|
140
|
-
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '',
|
|
140
|
+
X_FOUNDING_BIRTH: '', X_FOUNDING_GENESIS: '', X_DEMO_PEOPLE: '', X_DEMO_HOURS: '', X_SHIP_MODE: '', X_TEST_PROJECT: '',
|
|
141
141
|
}, 'defaults, explicitly');
|
|
142
142
|
});
|
|
143
143
|
|