@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.
Files changed (35) hide show
  1. package/.bongos-core.json +49 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +56 -2
  8. package/docs/api-reference.md +4 -3
  9. package/docs/copy-inventory.md +12 -12
  10. package/docs/copy-registry.json +13 -13
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +49 -52
  13. package/modules/hall-ui/public/shell-game.js +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/paid-shape-gate.js +3 -1
  20. package/modules/provisioning/provisioning.js +3 -3
  21. package/modules/provisioning/routes/provisioning.js +7 -4
  22. package/modules/provisioning/test-project.js +65 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +10 -0
  26. package/scripts/bongos/government-walk.js +2 -2
  27. package/src/branding.js +7 -0
  28. package/src/module-api.js +1 -1
  29. package/tests/currency_label.mjs +3 -1
  30. package/tests/hall_start_screen.mjs +62 -8
  31. package/tests/provision_settings_apply.mjs +2 -1
  32. package/tests/provisioning_paid_shape_gate.mjs +16 -0
  33. package/tests/provisioning_settings_env.mjs +2 -2
  34. package/tests/test_project.mjs +122 -0
  35. 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.startFounding(req.builder.id)));
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
- 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)
@@ -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 an explicit `genesis: true` (the test toggle) is born into it.
481
- 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));
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.5",
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.5",
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.5",
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",
@@ -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
- // 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');
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.5'; // 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');
@@ -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: { getItem: () => null, setItem() {} },
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('a visit that is not a fresh sign-in keeps the welcome, and a hall without shell-game.js greets a sign-in as before', () => {
109
- const daily = sandbox();
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