@bongos/core 1.25.19 → 1.25.20

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 (78) hide show
  1. package/.bongos-core.json +108 -78
  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 +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +36 -2
  8. package/docs/api-reference.md +5 -4
  9. package/docs/copy-inventory.md +217 -215
  10. package/docs/copy-registry.json +256 -238
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +348 -347
  13. package/docs/project-context.template.md +1 -1
  14. package/modules/front-door-guide/handoff.js +120 -0
  15. package/modules/front-door-guide/module.json +1 -1
  16. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/deploy.html +1 -1
  26. package/modules/hall-ui/public/genesis-home.js +23 -4
  27. package/modules/hall-ui/public/goal-map.html +1 -1
  28. package/modules/hall-ui/public/goals.html +1 -1
  29. package/modules/hall-ui/public/government.html +1 -1
  30. package/modules/hall-ui/public/home-city-draw.js +14 -1
  31. package/modules/hall-ui/public/idea.html +1 -1
  32. package/modules/hall-ui/public/ideas.html +1 -1
  33. package/modules/hall-ui/public/index.html +1 -1
  34. package/modules/hall-ui/public/market-watch-item.html +1 -1
  35. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  36. package/modules/hall-ui/public/market-watch.html +1 -1
  37. package/modules/hall-ui/public/modules.html +1 -1
  38. package/modules/hall-ui/public/money.html +1 -1
  39. package/modules/hall-ui/public/my-agents.html +1 -1
  40. package/modules/hall-ui/public/primer.html +1 -1
  41. package/modules/hall-ui/public/profile.html +1 -1
  42. package/modules/hall-ui/public/project-settings.html +1 -1
  43. package/modules/hall-ui/public/roster.html +1 -1
  44. package/modules/hall-ui/public/settings.html +1 -1
  45. package/modules/hall-ui/public/shell.js +32 -20
  46. package/modules/hall-ui/public/street.html +1 -1
  47. package/modules/hall-ui/public/studio.html +1 -1
  48. package/modules/hall-ui/public/style.css +9 -4
  49. package/modules/hall-ui/public/task.html +1 -1
  50. package/modules/hall-ui/public/thinking.html +1 -1
  51. package/modules/hall-ui/public/tweak-editor.html +1 -1
  52. package/modules/hall-ui/public/watch.html +1 -1
  53. package/modules/hall-ui/public/work.html +1 -1
  54. package/modules/lifecycle/home-city.js +17 -3
  55. package/modules/onboarding/founding-mode.js +45 -1
  56. package/modules/onboarding/genesis-home.js +2 -0
  57. package/modules/onboarding/guide-handoff.js +109 -0
  58. package/modules/onboarding/routes/genesis-work.js +9 -5
  59. package/modules/provisioning/guide-handoff.js +65 -0
  60. package/modules/provisioning/provisioning.js +2 -2
  61. package/modules/provisioning/routes/provisioning.js +4 -2
  62. package/modules/public-landing/public/projects.html +79 -0
  63. package/package-lock.json +2 -2
  64. package/package.json +1 -1
  65. package/release-notes.json +10 -0
  66. package/src/bongos/founding-page-gate.js +67 -0
  67. package/src/bongos/serve-internal.js +3 -0
  68. package/src/branding.js +4 -0
  69. package/src/module-api.js +1 -1
  70. package/tests/founding_mode.mjs +84 -8
  71. package/tests/founding_page_gate.mjs +105 -0
  72. package/tests/front_door_guide_existing.mjs +1 -0
  73. package/tests/genesis_home.mjs +24 -0
  74. package/tests/guide_handoff.mjs +322 -0
  75. package/tests/home_city.mjs +52 -0
  76. package/tests/nav_permission_atoms.mjs +1 -1
  77. package/tests/provision_settings_apply.mjs +2 -1
  78. package/tests/provisioning_settings_env.mjs +2 -2
@@ -1579,6 +1579,8 @@ summary{min-height:24px;padding:3px 0;}
1579
1579
  <h2 id="p1-h"><span class="n">01</span>Your project</h2>
1580
1580
  <!-- auto-fill: the answers this account gave on its most recent project -->
1581
1581
  <p class="fillOffer" id="physFillOffer" hidden><button type="button" class="linklike" id="physFromAccount">Fill these in from my last project</button></p>
1582
+ <!-- the guide's answers (task 1005274): shown only when this page filled itself in from the plan -->
1583
+ <p class="fillOffer hint" id="guideFill" hidden>Filled in from your talk with the guide. Change anything you like. <a id="guideFileLink" href="#" download="PROJECT-HANDOFF.md">Download the plan for your AI</a></p>
1582
1584
  <!-- the demo route (task 1004420, BV2.PS09): opened in place at the top of part
1583
1585
  01 by the small link under the heading; the rest of the page is unchanged
1584
1586
  and marked "demo" (D9). The fit check is a copy of modules/provisioning/demo.js
@@ -5213,6 +5215,77 @@ summary{min-height:24px;padding:3px 0;}
5213
5215
  });
5214
5216
  paintTeam(state.teamShape || null);
5215
5217
 
5218
+ /* ── the guide's answers (task 1005274) ──
5219
+ A founder who talked with the start-a-project guide (modules/front-door-guide) is
5220
+ never asked the same thing twice. This browser holds their chat in localStorage
5221
+ (the landing's guide-scene.js writes it); once they are signed in, the chat moves
5222
+ onto their account (POST …/attach) and its plan fills in what it covers, and ONLY
5223
+ what is still unanswered: the kind, the sentence, the extras. The name stays the
5224
+ founder's (the plan has none). Every field stays the ordinary control it always
5225
+ was, so each answer is editable. The create then says from_guide, and the hub
5226
+ carries the plan's scope answers and the hand-off file into the new project
5227
+ (modules/provisioning/guide-handoff.js). No chat in this browser: nothing is read
5228
+ and nothing changes. */
5229
+ var GUIDE_KEY = 'cb-front-door-guide';
5230
+ var guideAsked = false;
5231
+ function guideChat() {
5232
+ try {
5233
+ if (!(window.__MODULES__ && window.__MODULES__.enabled && window.__MODULES__.enabled['front-door-guide'])) return null;
5234
+ var g = JSON.parse(localStorage.getItem(GUIDE_KEY) || 'null');
5235
+ return g && g.id && g.token ? g : null;
5236
+ } catch (e) { return null; }
5237
+ }
5238
+ /* applyGuidePlan(plan) — fills the unanswered fields from a plan; returns what it filled. */
5239
+ function applyGuidePlan(plan) {
5240
+ var filled = [];
5241
+ if (!plan || typeof plan !== 'object') return filled;
5242
+ if (!state.projType && plan.project_type && root.querySelector('#typeCards .tpill[data-type="' + plan.project_type + '"]')) {
5243
+ state.projType = plan.project_type; filled.push('type');
5244
+ }
5245
+ if (!String(state.description || '').trim() && typeof plan.summary === 'string' && plan.summary.trim()) {
5246
+ state.description = plan.summary.trim().slice(0, 600); filled.push('description');
5247
+ }
5248
+ if (state.modules === null && Array.isArray(plan.modules) && plan.modules.length) {
5249
+ state.modules = plan.modules.map(function (m) { return m && m.key; }).filter(function (k) { return typeof k === 'string' && k; });
5250
+ filled.push('modules');
5251
+ }
5252
+ return filled;
5253
+ }
5254
+ function paintGuideFill() {
5255
+ var note = $('guideFill');
5256
+ if (!note) return;
5257
+ note.hidden = !state.fromGuide;
5258
+ $('guideFileLink').href = API + '/front-door-guide/mine/handoff.md?name=' + encodeURIComponent(String(state.name || '').trim());
5259
+ }
5260
+ function guidePrefill() {
5261
+ if (guideAsked || !me) return;
5262
+ var g = guideChat();
5263
+ if (!g) return;
5264
+ guideAsked = true;
5265
+ fetch(API + '/front-door-guide/conversations/' + encodeURIComponent(g.id) + '/attach', {
5266
+ method: 'POST', credentials: 'same-origin',
5267
+ headers: { 'Content-Type': 'application/json' },
5268
+ body: JSON.stringify({ token: g.token })
5269
+ }).catch(function () { return null; }).then(function () {
5270
+ return fetch(API + '/front-door-guide/mine', { credentials: 'same-origin' });
5271
+ }).then(function (res) {
5272
+ return res && res.ok ? res.json() : null;
5273
+ }).then(function (data) {
5274
+ var plan = data && data.conversation && data.conversation.plan;
5275
+ if (!plan) return;
5276
+ applyGuidePlan(plan);
5277
+ state.fromGuide = true;
5278
+ saveDraft();
5279
+ paintType(state.projType || null);
5280
+ $('f-desc').value = state.description || '';
5281
+ renderModules();
5282
+ paintSetup();
5283
+ paintCreate();
5284
+ paintGuideFill();
5285
+ }).catch(function () { /* the guide is a convenience: the page works without it */ });
5286
+ }
5287
+ paintGuideFill();
5288
+
5216
5289
  /* ── the module picker (task 1002339, BV1.R20) ──
5217
5290
  ONE public read gives the panel both halves it needs: `optional` is the
5218
5291
  catalog it renders, `bundles` is what each project type preselects
@@ -6933,6 +7006,9 @@ summary{min-height:24px;padding:3px 0;}
6933
7006
  has commits rejects as unrelated history. state.mode already tracked
6934
7007
  greenfield|adopt for the UI — it has to reach the server too. */
6935
7008
  onboard_mode: state.mode === 'adopt' ? 'adopt' : 'greenfield',
7009
+ /* the page filled itself in from the guide's plan (task 1005274): the hub
7010
+ carries the scope answers and the hand-off file into the new project */
7011
+ from_guide: state.fromGuide ? true : undefined,
6936
7012
  /* the ship row (task 1004629): becomes the new project's own ship mode */
6937
7013
  ship_mode: shipModeChoice()
6938
7014
  })
@@ -6963,6 +7039,8 @@ summary{min-height:24px;padding:3px 0;}
6963
7039
  demo_invites: state.demo && state.demo.invites && state.demo.invites.length ? state.demo.invites.slice() : undefined
6964
7040
  }));
6965
7041
  sessionStorage.removeItem(DRAFT_KEY);
7042
+ /* the guide's chat has done its job: a second project must not fill in from it */
7043
+ if (state.fromGuide) localStorage.removeItem(GUIDE_KEY);
6966
7044
  } catch (e) {}
6967
7045
  createBusy = false;
6968
7046
  renderDone();
@@ -8568,6 +8646,7 @@ summary{min-height:24px;padding:3px 0;}
8568
8646
  $('f-owner').value = state.owner;
8569
8647
  saveDraft();
8570
8648
  }
8649
+ guidePrefill();
8571
8650
  if (currentView === 'new') enter(false);
8572
8651
  }
8573
8652
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.25.19",
3
+ "version": "1.25.20",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.25.19",
9
+ "version": "1.25.20",
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.19",
3
+ "version": "1.25.20",
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",
@@ -11858,5 +11858,15 @@
11858
11858
  "id": "1005115",
11859
11859
  "text": "A solo founder's Genesis now moves to the next stage by itself when a stage's work is finished, and every stage leaves a named document (the scope, the strategy...) they can come back and edit any time."
11860
11860
  }
11861
+ ],
11862
+ "1.25.20": [
11863
+ {
11864
+ "id": "1005112",
11865
+ "text": "While a new project is being set up, the menu now shows only the rooms that are open, a room that just opened is highlighted, a closed room can't be reached by its address, and the later setup stages say what finishing them ea"
11866
+ },
11867
+ {
11868
+ "id": "1005274",
11869
+ "text": "A founder who planned their project with the guide now finds the project form and the first Genesis question already filled in, plus a plan file for their own AI."
11870
+ }
11861
11871
  ]
11862
11872
  }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/founding-page-gate.js — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
4
+ //
5
+ // While a project is being founded, the hall's sidebar holds only the rooms its genesis
6
+ // stages have opened. Hiding a room and refusing its page are ONE change: a room nobody
7
+ // can see but anyone can type the address of would be a wall the product only claims to
8
+ // have. So this is the page half. It asks the server's own answer, the onboarding port's
9
+ // foundingModeFor() (the same read /me carries as founding_mode.asleep, keyed by the
10
+ // sidebar's room ids), and never a browser's opinion (ADR 0016).
11
+ //
12
+ // A sleeping room's page answers with a redirect home carrying ?asleep=<room>&opens=<stage>,
13
+ // where the shell says what the room is and which stage opens it, instead of a dead end.
14
+ //
15
+ // What it never touches: a project that is not founding (foundingModeFor answers null,
16
+ // and the page is served exactly as before), a room that is awake, the Primer and the
17
+ // Board Room (never asleep), and every page that is not one of the rooms below. An
18
+ // unreadable founding state is null too, so a failure opens the page, never shuts it:
19
+ // founding-mode.js fails closed to OFF for the same reason (a hall wrongly asleep would
20
+ // hijack it). Rooms are keyed by the sidebar id, and each room's page slug IS that id.
21
+
22
+ const path = require('path');
23
+ const seams = require('../module-seams');
24
+
25
+ // The rooms a genesis stage can put to sleep (founding-mode.js WAKES + WAKES_WHEN_ALIVE),
26
+ // by page slug. tests/founding_page_gate.mjs holds this list to that map.
27
+ const ROOM_PAGE_RE = /^\/builders\/(work|goals|goal-map|collab|roster)(?:\.html)?\/?$/;
28
+
29
+ // asleepRoomFor(room, founding) — PURE. The room id when that room sleeps under this
30
+ // founding answer, else null.
31
+ function asleepRoomFor(room, founding) {
32
+ if (!founding || founding.active !== true || !founding.asleep || typeof founding.asleep !== 'object') return null;
33
+ return Object.prototype.hasOwnProperty.call(founding.asleep, room) ? room : null;
34
+ }
35
+
36
+ // readFounding() — the server's answer, or null on any failure (fail open: OFF).
37
+ async function readFounding(resolve = seams.resolveOptional) {
38
+ try {
39
+ const onboarding = resolve('onboarding');
40
+ if (!onboarding || typeof onboarding.foundingModeFor !== 'function') return null;
41
+ return await onboarding.foundingModeFor();
42
+ } catch (_) {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ // foundingPageGate({ skip, isBuildersHost, read }) — the Express middleware. `skip(req)`
48
+ // is the caller's module-surface test; `read` is injectable for tests. The founding read
49
+ // is made only for one of the five room pages, so every other request pays nothing.
50
+ function foundingPageGate({ skip = () => false, isBuildersHost = () => false, read = readFounding } = {}) {
51
+ return async function gate(req, res, next) {
52
+ if (skip(req) || !String(req.path || '').startsWith('/builders')) return next();
53
+ const m = ROOM_PAGE_RE.exec(path.posix.normalize(req.path));
54
+ if (!m) return next();
55
+ let founding = null;
56
+ try { founding = await read(); } catch (_) { founding = null; } // fail open: OFF
57
+ const room = asleepRoomFor(m[1], founding);
58
+ if (!room) return next();
59
+ const opens = String(founding.asleep[room] || '');
60
+ res.set('Cache-Control', 'no-store');
61
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/?asleep=${encodeURIComponent(room)}`
62
+ + (/^[a-z]{1,20}$/.test(opens) ? `&opens=${opens}` : ''));
63
+ };
64
+ }
65
+
66
+ // One export: the pure parts are proven through it (tests/founding_page_gate.mjs).
67
+ module.exports = { foundingPageGate };
@@ -50,6 +50,7 @@ const { linkPreviewTags } = require('../link-preview');
50
50
  const { prelaunchGate } = require('./prelaunch-gate');
51
51
  const { platformVisibilityGate } = require('./platform-visibility-gate');
52
52
  const { schemaPending } = require('./schema-lag');
53
+ const { foundingPageGate } = require('./founding-page-gate');
53
54
  const { clientModules, isModuleEnabled } = require('../modules');
54
55
  const { hallWidgetScripts, moduleWebSurfaces } = require('../module-loader/loader');
55
56
  const { resolveCoreRoot, resolveDocsRoot, resolveInstanceRoot, resolveEnv } = require('../instance-config');
@@ -1353,6 +1354,8 @@ function mountInternalSurfaces(app) {
1353
1354
  }
1354
1355
  next();
1355
1356
  });
1357
+ // task 1005112: a founding project's sleeping room is refused by URL, after sign-in.
1358
+ app.use(foundingPageGate({ skip: isModuleSurfaceHost, isBuildersHost }));
1356
1359
 
1357
1360
  // task 1965: the read-only Task Board (/board, task 751) was consolidated into
1358
1361
  // the Work page (/work) — the hall keeps one task-listing surface. 301 old
package/src/branding.js CHANGED
@@ -112,6 +112,10 @@ const ENV_OVERRIDES = [
112
112
  // The ship mode the founder picked in the wizard (task 1004629), empty when none was.
113
113
  // resolveShipMode reads it only while project_settings['ship.mode'] is unset.
114
114
  ['SHIP_MODE', ['founding', 'shipMode']],
115
+ // The start-a-project guide's hand-off (task 1005274, modules/provisioning/guide-handoff.js): base64url
116
+ // JSON { scope, markdown }, empty when the founder never used the guide. modules/onboarding/guide-handoff.js
117
+ // writes it once: the scope answers as carried over, and the hand-off file. SERVER-ONLY, like the birth.
118
+ ['GUIDE_HANDOFF', ['founding', 'guideHandoff']],
115
119
  ];
116
120
 
117
121
  const isObj = (v) => v && typeof v === 'object' && !Array.isArray(v);
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.19'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.25.20'; // 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');
@@ -143,7 +143,8 @@ function makeHall() {
143
143
  documentElement: root,
144
144
  createElement: (t) => new El(t),
145
145
  querySelector: (sel) => (sel === '.app-body' ? appBody : null),
146
- querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep' ? nav.filter((a) => a.classList.contains('nav-item--asleep')) : []),
146
+ querySelectorAll: (sel) => (sel === '#app-sidebar .nav-item--asleep, #app-sidebar .nav-item--arrived'
147
+ ? nav.filter((a) => a.classList.contains('nav-item--asleep') || a.classList.contains('nav-item--arrived')) : []),
147
148
  getElementById: (id) => nav.find((a) => a.id === id) || appBody.children.find((c) => c.id === id) || null,
148
149
  };
149
150
  return { document, nav, appBody, root };
@@ -164,12 +165,12 @@ function sliceFn(name) {
164
165
  assert.ok(start > -1 && end > start, name + ' was located in shell.js');
165
166
  return SHELL.slice(start, end + 4);
166
167
  }
167
- function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }) {
168
+ function loadApplyFounding(document, access, brand = { identity: { productName: 'Kestrel' } }, storage = null) {
168
169
  const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
169
170
  const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
170
171
  assert.ok(wakeStart > -1, 'WAKE_TEXT was located in shell.js');
171
172
  const src = [SHELL.slice(wakeStart, wakeEnd), sliceEsc(), sliceFn('applyFounding')].join('\n');
172
- return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB() });
173
+ return new Function('document', 'brand', 'access', 'window', `${src}\nreturn applyFounding;`)(document, brand, access, { OTB: realOTB(), localStorage: storage || undefined });
173
174
  }
174
175
 
175
176
  const snapshot = (h) => JSON.stringify({
@@ -213,8 +214,8 @@ test('founding ON: the band above the topbar, and the rooms the server named asl
213
214
  const byId = (id) => h.nav.find((a) => a.id === 'nav-' + id);
214
215
  assert.ok(!byId('goals').classList.contains('nav-item--asleep'), 'scope is behind: goals is awake');
215
216
  assert.ok(byId('work').classList.contains('nav-item--asleep'), 'strategize is current: tasks still sleeps');
216
- assert.equal(byId('work').children[0].textContent, 'opens after strategy', 'a sentence, never a raw slug');
217
- assert.equal(byId('collab').children[0].textContent, 'opens when alive');
217
+ assert.equal(byId('work').children.length, 0, 'a hidden room carries no label nobody can see (task 1005112)');
218
+ assert.ok(byId('collab').classList.contains('nav-item--asleep'), 'community sleeps until alive');
218
219
  assert.ok(!byId('home').classList.contains('nav-item--asleep'), 'home is never put to sleep');
219
220
  });
220
221
 
@@ -227,7 +228,7 @@ test('founding OFF after ON restores the hall exactly — OFF removes, it does n
227
228
  assert.notEqual(snapshot(h), before);
228
229
  access.founding = null;
229
230
  apply();
230
- assert.equal(snapshot(h), before, 'band, asleep classes, wake tags and the root attribute are all gone');
231
+ assert.equal(snapshot(h), before, 'band, asleep classes and the root attribute are all gone');
231
232
  });
232
233
 
233
234
  test('the band escapes what it prints — a project name cannot inject markup', () => {
@@ -280,13 +281,13 @@ test('a failing waiting-on source never turns the mode off', async () => {
280
281
  assert.equal(f.waiting_on, null);
281
282
  });
282
283
 
283
- test('re-applying ON does not stack a second band or duplicate wake tags', () => {
284
+ test('re-applying ON does not stack a second band or add anything to a room', () => {
284
285
  const h = makeHall();
285
286
  const access = { founding: fm.shapeFoundingMode({ active: 'true', stage: 'decide' }) };
286
287
  const apply = loadApplyFounding(h.document, access);
287
288
  apply(); apply();
288
289
  assert.equal(h.appBody.children.filter((c) => c.id === 'founding-band').length, 1);
289
- assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 1);
290
+ assert.equal(h.nav.find((a) => a.id === 'nav-work').children.length, 0);
290
291
  });
291
292
 
292
293
  // ── the wiring ──────────────────────────────────────────────────────────────
@@ -317,3 +318,78 @@ test('founding mode adds no page, no route and no nav item — it is a mode', ()
317
318
  const hall = fs.readdirSync(path.join(ROOT, 'modules', 'hall-ui', 'public'));
318
319
  assert.ok(!hall.some((f) => /^founding\./.test(f)), 'no founding page file');
319
320
  });
321
+
322
+ // ── task 1005112: hidden, not dimmed; the arrival; the honest refusal ─────────
323
+
324
+ test('a sleeping room is hidden by the sheet, and a group of sleeping rooms hides its head', () => {
325
+ const css = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'style.css'), 'utf8');
326
+ assert.match(css, /\.nav-item--asleep \{ display: none; \}/, 'asleep is not drawn at all, never dimmed');
327
+ assert.doesNotMatch(css, /\.nav-item--asleep \{ opacity/);
328
+ assert.match(css, /html\[data-founding="on"\] \.nav-group:not\(:has\(\.nav-item:not\(\.nav-item--asleep\)/);
329
+ });
330
+
331
+ test('a room that woke since this browser last looked arrives marked, once (task 1005112)', () => {
332
+ const store = new Map();
333
+ const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
334
+ const at = (stage) => ({ founding: fm.shapeFoundingMode({ active: 'true', stage }) });
335
+ const h1 = makeHall();
336
+ loadApplyFounding(h1.document, at('scope'), undefined, storage)();
337
+ assert.ok(!h1.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the first look marks nothing');
338
+ const h2 = makeHall();
339
+ loadApplyFounding(h2.document, at('strategize'), undefined, storage)();
340
+ const arrived = h2.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id);
341
+ assert.deepEqual(arrived.sort(), ['nav-goal-map', 'nav-goals'], 'scope finished: Goals and Sky arrive');
342
+ const h3 = makeHall();
343
+ loadApplyFounding(h3.document, at('strategize'), undefined, storage)();
344
+ assert.ok(!h3.nav.some((a) => a.classList.contains('nav-item--arrived')), 'the next page load: no mark again');
345
+ // Switching off removes the mark with everything else.
346
+ const access = at('charter');
347
+ const h4 = makeHall();
348
+ const before = snapshot(h4);
349
+ const apply = loadApplyFounding(h4.document, access, undefined, storage);
350
+ apply();
351
+ access.founding = null;
352
+ apply();
353
+ assert.equal(snapshot(h4), before);
354
+ });
355
+
356
+ test('the shell says, on Home, what a refused room is and which stage opens it (task 1005112)', () => {
357
+ const navStart = SHELL.indexOf(' var NAV = [');
358
+ const navEnd = SHELL.indexOf(' var NAV_COLLAPSE_KEY');
359
+ const wakeStart = SHELL.indexOf(' var WAKE_TEXT = {');
360
+ const wakeEnd = SHELL.indexOf('};', wakeStart) + 2;
361
+ const src = [SHELL.slice(navStart, navEnd), SHELL.slice(wakeStart, wakeEnd), sliceEsc(),
362
+ sliceFn('navItemById'), sliceFn('renderDeniedNotice')].join('\n');
363
+ let inserted = null;
364
+ let replaced = null;
365
+ const el = { querySelector: () => ({ addEventListener() {} }), setAttribute() {} };
366
+ const document = {
367
+ getElementById: (id) => (id === 'app-topbar' ? { insertAdjacentElement: (_, n) => { inserted = n; } } : null),
368
+ createElement: () => el,
369
+ };
370
+ const run = (search) => {
371
+ inserted = null;
372
+ new Function('document', 'window', 'history', 'brand', 'access', 'icon', 'chevronIcon',
373
+ `${src}\nreturn renderDeniedNotice;`)(document,
374
+ { location: { search, pathname: '/', hash: '' }, OTB: realOTB() },
375
+ { replaceState: (a, b, url) => { replaced = url; } }, {}, {}, () => '', () => '')();
376
+ return inserted;
377
+ };
378
+ const n = run('?asleep=goals&opens=scope');
379
+ assert.ok(n, 'a notice is drawn');
380
+ assert.match(n.innerHTML, /Goals isn’t open yet\. It opens after scope\. Finish that stage of founding here on Home\./);
381
+ assert.equal(replaced, '/', 'the query is stripped so a refresh does not re-show it');
382
+ assert.equal(run('?asleep=goals%3Cx&opens=scope'), null, 'a malformed room id draws nothing');
383
+ assert.match(run('?denied=page.view.government').innerHTML, /permission/, 'the old denial still reads the same');
384
+ });
385
+
386
+ test('the arrival mark survives the shell applying the mode a second time on one page', () => {
387
+ const store = new Map([['hall-founding-asleep', JSON.stringify({ goals: 'scope', 'goal-map': 'scope', 'board-room': 'charter' })]]);
388
+ const storage = { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)) };
389
+ const h = makeHall();
390
+ const apply = loadApplyFounding(h.document, { founding: fm.shapeFoundingMode({ active: 'true', stage: 'strategize' }) }, undefined, storage);
391
+ apply();
392
+ apply(); // /me settles, then the page reports: applyAccess runs again
393
+ const arrived = h.nav.filter((a) => a.classList.contains('nav-item--arrived')).map((a) => a.id).sort();
394
+ assert.deepEqual(arrived, ['nav-board-room', 'nav-goal-map', 'nav-goals']);
395
+ });
@@ -0,0 +1,105 @@
1
+ // tests/founding_page_gate.mjs — A SLEEPING ROOM IS NOT REACHABLE BY URL (task 1005112).
2
+ //
3
+ // The sidebar hides a room a founding project has not opened; this is the other half,
4
+ // the page. Everything here RUNS the real middleware (src/bongos/founding-page-gate.js)
5
+ // over fake requests, fed by the real founding-mode shape (modules/onboarding/
6
+ // founding-mode.js) — and, in the last cases, through the real port lookup — so the
7
+ // server's /me answer and the page refusal are one rule.
8
+
9
+ import { strict as assert } from 'node:assert';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+ import fs from 'node:fs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const { foundingPageGate } = require('../src/bongos/founding-page-gate.js');
16
+ const seams = require('../src/module-seams.js');
17
+ const fm = require('../modules/onboarding/founding-mode.js');
18
+
19
+ const founding = (stage) => fm.shapeFoundingMode({ active: 'true', stage });
20
+ const ROOMS = [...new Set([...Object.values(fm.WAKES).flat(), ...fm.WAKES_WHEN_ALIVE])];
21
+
22
+ // Drive the middleware once; resolve with what it did.
23
+ function run(pathname, { read, buildersHost = false, skip = false } = {}) {
24
+ return new Promise((resolve) => {
25
+ const res = {
26
+ headers: {},
27
+ set(k, v) { this.headers[k] = v; return this; },
28
+ redirect(code, url) { resolve({ redirect: url, code, headers: this.headers }); },
29
+ };
30
+ const opts = { isBuildersHost: () => buildersHost, skip: () => skip };
31
+ if (read) opts.read = read;
32
+ foundingPageGate(opts)({ path: pathname }, res, () => resolve({ next: true }));
33
+ });
34
+ }
35
+
36
+ test('every room a genesis stage can put to sleep is refused while it sleeps, and nothing else is', async () => {
37
+ // At 'decide' every room sleeps, so this is the whole list the gate must cover.
38
+ const read = async () => founding('decide');
39
+ for (const id of ROOMS) {
40
+ const out = await run(`/builders/${id}`, { read });
41
+ assert.ok(out.redirect, `${id} is refused while asleep`);
42
+ }
43
+ for (const never of ['primer', 'board-room', 'profile', 'task/12', 'settings', 'blockers', 'studio']) {
44
+ assert.deepEqual(await run(`/builders/${never}`, { read }), { next: true }, `${never} is never gated`);
45
+ }
46
+ // And the gate names no room the server never puts to sleep: a fake answer naming
47
+ // an unknown room changes nothing.
48
+ assert.deepEqual(await run('/builders/blockers', { read: async () => ({ active: true, asleep: { blockers: 'scope' } }) }), { next: true });
49
+ });
50
+
51
+ test('a sleeping room\'s page goes home, naming the room and the stage that opens it', async () => {
52
+ const out = await run('/builders/goals', { read: async () => founding('scope') });
53
+ assert.equal(out.code, 302);
54
+ assert.equal(out.redirect, '/builders/?asleep=goals&opens=scope');
55
+ assert.equal(out.headers['Cache-Control'], 'no-store', 'a refusal is never cached past the wake');
56
+ const sub = await run('/builders/work.html', { read: async () => founding('scope'), buildersHost: true });
57
+ assert.equal(sub.redirect, '/?asleep=work&opens=strategize', 'the builders. host form');
58
+ const alive = await run('/builders/roster/', { read: async () => founding('legislation') });
59
+ assert.equal(alive.redirect, '/builders/?asleep=roster&opens=alive');
60
+ const sneaky = await run('/builders//goal-map', { read: async () => founding('decide') });
61
+ assert.equal(sneaky.redirect, '/builders/?asleep=goal-map&opens=scope', 'a doubled slash is normalised, not a way past');
62
+ });
63
+
64
+ test('an awake room opens, and a later stage never takes one away', async () => {
65
+ assert.deepEqual(await run('/builders/goals', { read: async () => founding('strategize') }), { next: true });
66
+ assert.deepEqual(await run('/builders/work', { read: async () => founding('charter') }), { next: true });
67
+ });
68
+
69
+ test('a project that is not founding is untouched, and a failed read opens rather than shuts', async () => {
70
+ assert.deepEqual(await run('/builders/goals', { read: async () => null }), { next: true });
71
+ assert.deepEqual(await run('/builders/collab', { read: async () => ({ active: false }) }), { next: true });
72
+ assert.deepEqual(await run('/builders/goals', { read: async () => { throw new Error('db down'); } }), { next: true },
73
+ 'a read that throws opens the page');
74
+ });
75
+
76
+ test('only the five room pages ever cost a founding read', async () => {
77
+ let reads = 0;
78
+ const read = async () => { reads += 1; return founding('decide'); };
79
+ for (const p of ['/builders/', '/builders/primer', '/builders/board-room', '/builders/task/5', '/api/bongos/me', '/goals']) {
80
+ assert.deepEqual(await run(p, { read }), { next: true }, p);
81
+ }
82
+ assert.deepEqual(await run('/builders/goals', { read, skip: true }), { next: true }, 'a module surface host is skipped');
83
+ assert.equal(reads, 0);
84
+ });
85
+
86
+ test('the default read is the onboarding port\'s foundingModeFor, the answer /me carries', async () => {
87
+ seams._reset();
88
+ try {
89
+ assert.deepEqual(await run('/builders/goals'), { next: true }, 'onboarding module off: not founding, page opens');
90
+ seams.registerProvider('onboarding', { foundingModeFor: async () => founding('scope') });
91
+ assert.equal((await run('/builders/goals')).redirect, '/builders/?asleep=goals&opens=scope');
92
+ seams._reset();
93
+ seams.registerProvider('onboarding', { foundingModeFor: async () => { throw new Error('settings table gone'); } });
94
+ assert.deepEqual(await run('/builders/goals'), { next: true }, 'a port that throws opens the page');
95
+ } finally {
96
+ seams._reset();
97
+ }
98
+ });
99
+
100
+ test('the server mounts it after the sign-in gate, so a stranger still meets sign-in first', () => {
101
+ const src = fs.readFileSync(new URL('../src/bongos/serve-internal.js', import.meta.url), 'utf8');
102
+ const signIn = src.indexOf('return gdsAuth.requireBuilderPage(req, res, next);');
103
+ const mount = src.indexOf('app.use(foundingPageGate(');
104
+ assert.ok(signIn > 0 && mount > signIn, 'mounted, and after the sign-in gate');
105
+ });
@@ -222,6 +222,7 @@ test('the guide has no upload, fetch or link-reading path: these are all its rou
222
222
  assert.deepEqual([...routes(chatRouter), ...routes(planRouter)].sort(), [
223
223
  'GET /front-door-guide/conversations/:id',
224
224
  'GET /front-door-guide/mine',
225
+ 'GET /front-door-guide/mine/handoff.md', // the plan as the hand-off file, a download (task 1005274): reads nothing of theirs
225
226
  'GET /front-door-guide/status',
226
227
  'POST /front-door-guide/conversations',
227
228
  'POST /front-door-guide/conversations/:id/attach',
@@ -653,6 +653,8 @@ test('the stage reads as a checklist: done, open and locked, and a locked row of
653
653
  assert.match(row('302'), /class="genesis__task is-locked"/);
654
654
  assert.match(row('302'), /after Gather what already exists/, 'a locked row says what it waits on');
655
655
  assert.doesNotMatch(row('302'), /data-genesis-copy/, 'a locked row offers no prompt for work it cannot claim');
656
+ assert.doesNotMatch(row('301'), /sr-only/, 'an open row adds no hidden word its status pill could contradict');
657
+ assert.match(row('302'), /class="sr-only">Locked: /);
656
658
  assert.doesNotMatch(html, /is-finished|line-through/, 'nothing is drawn struck through');
657
659
  // The "done" row: the earlier stage is not the one on screen, so check the shape directly.
658
660
  const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
@@ -774,3 +776,25 @@ test('task 1005121: the close route answers 202 with the whole body for an open
774
776
  const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
775
777
  assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
776
778
  });
779
+
780
+ test('a stage that opens no room says what finishing it earns, never nothing (task 1005112)', async () => {
781
+ const genesis = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan });
782
+ const by = Object.fromEntries(genesis.stages.map((s) => [s.key, s]));
783
+ assert.equal(by.scope.earns, null, 'a stage that wakes rooms names the rooms instead');
784
+ assert.equal(by.strategize.earns, null);
785
+ for (const k of ['charter', 'legalize', 'legislation']) {
786
+ assert.deepEqual(by[k].wakes, [], `${k} wakes no room`);
787
+ assert.ok(by[k].earns && by[k].earns.length > 10, `${k} says what it earns`);
788
+ }
789
+ assert.match(by.legislation.earns, /every room opens/, 'the last stage is the one that opens everything');
790
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
791
+ await mountClickable(b);
792
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map/);
793
+ assert.match(b.host.innerHTML, /puts your government in force/);
794
+ assert.match(b.host.innerHTML, /settles how contributors are rewarded/);
795
+ // The district rises with the stages (task 1005112): each row also names what it raises.
796
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map · raises Planning/);
797
+ assert.match(b.host.innerHTML, /Raises Treasury · settles how contributors are rewarded/);
798
+ assert.match(b.host.innerHTML, /Raises Rules, Review, Disputes · brings the project to life/);
799
+ assert.deepEqual(by.legalize.raises, ['treasury']);
800
+ });