@bongos/core 1.25.19 → 1.25.21

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 (131) hide show
  1. package/.bongos-core.json +237 -112
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +26 -9
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +10 -0
  8. package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
  9. package/docs/api/openapi.json +444 -37
  10. package/docs/api-reference.md +11 -5
  11. package/docs/copy-inventory.md +223 -221
  12. package/docs/copy-registry.json +262 -244
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-readings.json +348 -347
  15. package/docs/project-context.template.md +1 -1
  16. package/modules/front-door-guide/handoff.js +120 -0
  17. package/modules/front-door-guide/module.json +1 -1
  18. package/modules/front-door-guide/routes/front-door-guide-plan.js +24 -0
  19. package/modules/government/board.js +8 -7
  20. package/modules/government/charter-advice.js +10 -3
  21. package/modules/government/charter.js +88 -4
  22. package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
  23. package/modules/government/routes/government.js +12 -12
  24. package/modules/government/sole-member.js +75 -0
  25. package/modules/hall-ui/public/ai-studio.html +1 -1
  26. package/modules/hall-ui/public/atlas.html +1 -1
  27. package/modules/hall-ui/public/blockers.html +1 -1
  28. package/modules/hall-ui/public/board-room.html +1 -1
  29. package/modules/hall-ui/public/city.html +1 -1
  30. package/modules/hall-ui/public/collab.html +1 -1
  31. package/modules/hall-ui/public/commands.html +1 -1
  32. package/modules/hall-ui/public/copy-desk.html +1 -1
  33. package/modules/hall-ui/public/deploy.html +1 -1
  34. package/modules/hall-ui/public/genesis-home.js +35 -9
  35. package/modules/hall-ui/public/goal-map.html +1 -1
  36. package/modules/hall-ui/public/goals.html +1 -1
  37. package/modules/hall-ui/public/government.html +1 -1
  38. package/modules/hall-ui/public/home-city-draw.js +14 -1
  39. package/modules/hall-ui/public/idea.html +1 -1
  40. package/modules/hall-ui/public/ideas.html +1 -1
  41. package/modules/hall-ui/public/index.html +1 -1
  42. package/modules/hall-ui/public/market-watch-item.html +1 -1
  43. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  44. package/modules/hall-ui/public/market-watch.html +1 -1
  45. package/modules/hall-ui/public/modules.html +1 -1
  46. package/modules/hall-ui/public/money.html +1 -1
  47. package/modules/hall-ui/public/my-agents.html +1 -1
  48. package/modules/hall-ui/public/primer.html +1 -1
  49. package/modules/hall-ui/public/profile.html +1 -1
  50. package/modules/hall-ui/public/project-settings.html +1 -1
  51. package/modules/hall-ui/public/roster.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +32 -20
  54. package/modules/hall-ui/public/street.html +1 -1
  55. package/modules/hall-ui/public/studio.html +1 -1
  56. package/modules/hall-ui/public/style.css +9 -4
  57. package/modules/hall-ui/public/task.html +1 -1
  58. package/modules/hall-ui/public/thinking.html +1 -1
  59. package/modules/hall-ui/public/tweak-editor.html +1 -1
  60. package/modules/hall-ui/public/watch.html +1 -1
  61. package/modules/hall-ui/public/work.html +1 -1
  62. package/modules/lifecycle/home-city.js +17 -3
  63. package/modules/onboarding/founding-mode.js +45 -1
  64. package/modules/onboarding/genesis-home.js +16 -2
  65. package/modules/onboarding/guide-handoff.js +109 -0
  66. package/modules/onboarding/routes/genesis-work.js +9 -5
  67. package/modules/platform-identity/module.json +1 -1
  68. package/modules/platform-identity/project-standing.js +49 -0
  69. package/modules/platform-identity/routes/my-projects.js +8 -0
  70. package/modules/provisioning/grants.js +126 -0
  71. package/modules/provisioning/guide-handoff.js +65 -0
  72. package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
  73. package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
  74. package/modules/provisioning/module.json +2 -1
  75. package/modules/provisioning/project-refusal.js +107 -0
  76. package/modules/provisioning/provisioning.js +4 -4
  77. package/modules/provisioning/render-lookup.js +36 -1
  78. package/modules/provisioning/render-promote.js +80 -0
  79. package/modules/provisioning/route-helpers.js +70 -16
  80. package/modules/provisioning/routes/env-manifest.js +21 -5
  81. package/modules/provisioning/routes/grants.js +141 -0
  82. package/modules/provisioning/routes/provisioning.js +12 -9
  83. package/modules/provisioning/routes/render-standup.js +82 -3
  84. package/modules/public-landing/public/projects.html +178 -1
  85. package/package-lock.json +2 -2
  86. package/package.json +1 -1
  87. package/release-notes.json +28 -0
  88. package/scripts/bongos/genesis.js +11 -1
  89. package/scripts/bongos/government-walk-sole.js +41 -0
  90. package/scripts/bongos/government-walk.js +15 -16
  91. package/scripts/bongos/provision-render-common.js +48 -0
  92. package/scripts/bongos/provision-render-promote.js +93 -0
  93. package/scripts/bongos/provision-render-sandbox.js +14 -2
  94. package/scripts/bongos/provision-render.js +4 -36
  95. package/scripts/bongos/provision.js +1 -0
  96. package/scripts/bongos/render-api.js +17 -2
  97. package/scripts/gds/government-walk-sole.js +6 -0
  98. package/scripts/gds/provision-render-common.js +6 -0
  99. package/scripts/gds/provision-render-promote.js +6 -0
  100. package/src/bongos/founding-page-gate.js +67 -0
  101. package/src/bongos/serve-internal.js +3 -0
  102. package/src/branding.js +4 -0
  103. package/src/module-api.js +1 -1
  104. package/tests/fleet_cost_view.mjs +2 -0
  105. package/tests/founding_mode.mjs +84 -8
  106. package/tests/founding_page_gate.mjs +105 -0
  107. package/tests/front_door_guide_existing.mjs +1 -0
  108. package/tests/genesis_government.mjs +5 -1
  109. package/tests/genesis_home.mjs +41 -0
  110. package/tests/government_charter_advice.mjs +49 -0
  111. package/tests/government_charter_forms.mjs +14 -3
  112. package/tests/government_seed.mjs +1 -0
  113. package/tests/government_sole_member.mjs +153 -0
  114. package/tests/guide_handoff.mjs +322 -0
  115. package/tests/home_city.mjs +52 -0
  116. package/tests/nav_permission_atoms.mjs +1 -1
  117. package/tests/project_catalog_routes.mjs +2 -0
  118. package/tests/provision_settings_apply.mjs +2 -1
  119. package/tests/provisioning_env_manifest.mjs +4 -0
  120. package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
  121. package/tests/provisioning_project_grants.mjs +458 -0
  122. package/tests/provisioning_project_owner_gate.mjs +3 -1
  123. package/tests/provisioning_project_refusal.mjs +316 -0
  124. package/tests/provisioning_render_route.mjs +1 -1
  125. package/tests/provisioning_restart.mjs +2 -0
  126. package/tests/provisioning_settings.mjs +2 -0
  127. package/tests/provisioning_settings_apply.mjs +1 -1
  128. package/tests/provisioning_settings_env.mjs +2 -2
  129. package/tests/provisioning_teardown_intent.mjs +2 -2
  130. package/tests/render_promote.mjs +309 -0
  131. package/tests/render_sandbox.mjs +9 -4
@@ -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',
@@ -131,7 +131,11 @@ test('picking Monarchy files nothing: it is already the government in force', as
131
131
  const seen = await underMonarchy(async () => {
132
132
  const { out } = await decide({ government: 'monarchy' });
133
133
  assert.equal(out.ok, true);
134
- assert.deepEqual(out.charter, { state: 'in_force', form: 'monarchy' });
134
+ assert.equal(out.charter.state, 'in_force');
135
+ assert.equal(out.charter.form, 'monarchy');
136
+ // task 1005130: said plainly, and counted as done — never a silent no-op
137
+ assert.equal(out.charter.complete, true);
138
+ assert.match(out.charter.message, /already a Monarchy \/ BDFL\. Nothing to change/);
135
139
  });
136
140
  assert.equal(seen.filed, null);
137
141
  });
@@ -573,6 +573,23 @@ test('a demo\'s genesis card: no decide form, the settled plan in one line, each
573
573
  assert.doesNotMatch(host.innerHTML, /data-genesis-plan/, 'a demo never shows the decide form');
574
574
  });
575
575
 
576
+ test('the plan picker says what is in force, marks it, and says what each form asks day to day (task 1005130)', async () => {
577
+ const charter = require('../modules/government/charter.js');
578
+ const reco = require('../modules/government/recommendations.js');
579
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
580
+ const genesis = gh.shapeGenesis({ mode: { stage: 'decide', stage_number: 0, stage_count: 5, day: 1 }, plan: null });
581
+ // One stub answers every read: the genesis read and the constitution read.
582
+ const { host } = bootCard({ founding: true, data: {
583
+ genesis, viewer_id: '1', headcount: 1, charter: charter.charterView(MONARCHY), recommended: reco.recommendedCharter(1),
584
+ } });
585
+ for (let i = 0; i < 6; i++) await new Promise((r) => setImmediate(r));
586
+ assert.match(host.innerHTML, /This project runs as Monarchy \/ BDFL now\. Every project opens as one/, 'what is in force is said first');
587
+ assert.match(host.innerHTML, /Monarchy \/ BDFL · in force now · recommended/, 'the form in force is marked');
588
+ assert.match(host.innerHTML, /You decide\. Each decision takes one yes from you\./, 'monarchy says one yes is still needed');
589
+ assert.match(host.innerHTML, /Every builder on the project decides\./, 'every form says who decides');
590
+ assert.equal((host.innerHTML.match(/in force now/g) || []).length, 1, 'only the form in force is marked');
591
+ });
592
+
576
593
  test('the card lists every stage vote waiting on the viewer, and votes yes on all in order (task 1005199)', async () => {
577
594
  const me = [{ builder_id: '1', display_name: 'Founder' }];
578
595
  const genesis = gh.shapeGenesis({
@@ -653,6 +670,8 @@ test('the stage reads as a checklist: done, open and locked, and a locked row of
653
670
  assert.match(row('302'), /class="genesis__task is-locked"/);
654
671
  assert.match(row('302'), /after Gather what already exists/, 'a locked row says what it waits on');
655
672
  assert.doesNotMatch(row('302'), /data-genesis-copy/, 'a locked row offers no prompt for work it cannot claim');
673
+ assert.doesNotMatch(row('301'), /sr-only/, 'an open row adds no hidden word its status pill could contradict');
674
+ assert.match(row('302'), /class="sr-only">Locked: /);
656
675
  assert.doesNotMatch(html, /is-finished|line-through/, 'nothing is drawn struck through');
657
676
  // The "done" row: the earlier stage is not the one on screen, so check the shape directly.
658
677
  const done = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan,
@@ -774,3 +793,25 @@ test('task 1005121: the close route answers 202 with the whole body for an open
774
793
  const click = hall.slice(hall.indexOf("t.hasAttribute('data-genesis-close')"), hall.indexOf("t.hasAttribute('data-genesis-confirm')"));
775
794
  assert.ok(click.length > 0 && !click.includes('/votes'),'the hall no longer casts a second vote after closing — the server did it');
776
795
  });
796
+
797
+ test('a stage that opens no room says what finishing it earns, never nothing (task 1005112)', async () => {
798
+ const genesis = gh.shapeGenesis({ mode: { stage: 'scope', stage_number: 1, stage_count: 5, day: 1 }, plan: gh.parsePlan({}).plan });
799
+ const by = Object.fromEntries(genesis.stages.map((s) => [s.key, s]));
800
+ assert.equal(by.scope.earns, null, 'a stage that wakes rooms names the rooms instead');
801
+ assert.equal(by.strategize.earns, null);
802
+ for (const k of ['charter', 'legalize', 'legislation']) {
803
+ assert.deepEqual(by[k].wakes, [], `${k} wakes no room`);
804
+ assert.ok(by[k].earns && by[k].earns.length > 10, `${k} says what it earns`);
805
+ }
806
+ assert.match(by.legislation.earns, /every room opens/, 'the last stage is the one that opens everything');
807
+ const b = bootCard({ founding: true, data: { genesis, viewer_id: '1' } });
808
+ await mountClickable(b);
809
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map/);
810
+ assert.match(b.host.innerHTML, /puts your government in force/);
811
+ assert.match(b.host.innerHTML, /settles how contributors are rewarded/);
812
+ // The district rises with the stages (task 1005112): each row also names what it raises.
813
+ assert.match(b.host.innerHTML, /Wakes Goals, Goal map · raises Planning/);
814
+ assert.match(b.host.innerHTML, /Raises Treasury · settles how contributors are rewarded/);
815
+ assert.match(b.host.innerHTML, /Raises Rules, Review, Disputes · brings the project to life/);
816
+ assert.deepEqual(by.legalize.raises, ['treasury']);
817
+ });
@@ -148,3 +148,52 @@ test('the port entry reads the head count and the law book', async () => {
148
148
  assert.equal(expected.headcount, 2, 'the founder\'s expected number wins over the live count');
149
149
  assert.equal(expected.charter.key, 'monarchy');
150
150
  });
151
+
152
+ // ── task 1005130: every project is already a monarchy, and is told so ────────
153
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
154
+ const config = require(path.join(ROOT, 'modules', 'government', 'config.js'));
155
+
156
+ test('the default constitution reads as Monarchy, and the stage says so on arrival', () => {
157
+ const live = config.loadGovernmentConfig().board;
158
+ const v = charter.charterView(live);
159
+ assert.equal(v.in_force.form, 'monarchy', 'every project boots as a monarchy');
160
+ assert.match(v.in_force.line, /runs as Monarchy \/ BDFL now\. Every project opens as one/);
161
+ assert.match(v.in_force.line, /You decide\. Each decision takes one yes from you\./);
162
+ assert.deepEqual(v.forms.filter((f) => f.current).map((f) => f.key), ['monarchy'], 'exactly the form in force is marked');
163
+ });
164
+
165
+ test('every form says who decides and what closing takes, derived from its own settings', () => {
166
+ const v = charter.charterView(MONARCHY);
167
+ for (const f of v.forms.filter((x) => x.available)) {
168
+ const d = charter.dutyOf(f.franchise, f.pass_rule);
169
+ assert.equal(f.who_decides, d.who_decides, f.key);
170
+ assert.equal(f.to_close, d.to_close, f.key);
171
+ assert.ok(f.who_decides && f.to_close, `${f.key} says both`);
172
+ }
173
+ assert.equal(charter.dutyOf('founder', 'first_ratifier').to_close, 'Each decision takes one yes from you.');
174
+ assert.match(charter.dutyOf('everyone', 'majority').to_close, /more than half/);
175
+ assert.notEqual(charter.dutyOf('trusted', 'consent').to_close, charter.dutyOf('trusted', 'majority').to_close,
176
+ 'the sentence follows the pass rule, so a tuned rule is described as it runs');
177
+ });
178
+
179
+ test('choosing what is in force changes nothing and is done; choosing another says what happens next', () => {
180
+ const same = charter.choiceOutcome(MONARCHY, 'monarchy');
181
+ assert.equal(same.changes, false);
182
+ assert.equal(same.complete, true);
183
+ assert.match(same.line, /already a Monarchy \/ BDFL\. Nothing to change/);
184
+ const other = charter.choiceOutcome(MONARCHY, 'direct-democracy');
185
+ assert.equal(other.changes, true);
186
+ assert.match(other.line, /filed as a change to the rules and decided under the government in force \(Monarchy \/ BDFL\)\. Under it, your own one yes applies it\./);
187
+ const underCircle = charter.choiceOutcome({ ...MONARCHY, membership: 'rank:xenos+', pass_rule: 'consent' }, 'monarchy');
188
+ assert.match(underCircle.line, /passes unless one of them objects/, 'under another government the rule in force is named');
189
+ });
190
+
191
+ test('the charter stage advice carries what is in force and what choosing the recommendation does', () => {
192
+ const a = advice.buildAdvice({ answers: {}, headcount: 1, board: MONARCHY });
193
+ assert.equal(a.in_force.form, 'monarchy');
194
+ assert.equal(a.charter.key, 'monarchy');
195
+ assert.equal(a.charter.on_choose.changes, false, 'a solo founder is told the recommendation is what they already have');
196
+ const b = advice.buildAdvice({ answers: { final_say: 'everyone' }, headcount: 1, board: MONARCHY });
197
+ assert.equal(b.charter.on_choose.changes, true);
198
+ assert.ok(b.forms.find((f) => f.key === 'monarchy').current);
199
+ });
@@ -254,8 +254,12 @@ test('a charter that changes nothing files nothing, and a council must be a rank
254
254
  getRankByKey: async (key) => (key === 'trustees' ? { rank_key: 'trustees' } : null),
255
255
  builderHoldsRankActive: async () => true,
256
256
  }, async () => {
257
- assert.equal((await board.proposeCharter({ form: 'monarchy', proposedBy: '42' })).reason, 'no_change',
258
- 'a sitting on the constitution already in force would decide nothing');
257
+ const same = await board.proposeCharter({ form: 'monarchy', proposedBy: '42' });
258
+ assert.equal(same.reason, 'no_change', 'a sitting on the constitution already in force would decide nothing');
259
+ // task 1005130: the refusal says what is true in words and counts the choice done
260
+ assert.equal(same.in_force, 'Monarchy / BDFL');
261
+ assert.equal(same.complete, true);
262
+ assert.match(same.message, /You are already a Monarchy \/ BDFL\. Nothing to change/);
259
263
  assert.equal((await board.proposeCharter({ form: 'foundation', councilRank: 'ghosts', proposedBy: '42' })).reason, 'unknown_council_rank');
260
264
  assert.equal((await board.proposeCharter({ form: 'direct-democracy', proposedBy: null })).reason, 'missing_proposer');
261
265
  });
@@ -272,7 +276,14 @@ test('the constitution view carries the charter: forms, variables, and what the
272
276
  });
273
277
  const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
274
278
  assert.match(routes, /router\.post\('\/government\/board\/charter', api\.requireBuilder, api\.requirePermission\('board\.item\.open'\)/);
275
- assert.match(routes, /proposeCharter: board\.proposeCharter/);
279
+ // The port's charter filing IS board.proposeCharter, followed by the board-of-one
280
+ // confirm (task 1005130) — called, not read: a refusal from board comes back as is.
281
+ const { governmentPort } = require(path.join(GOV, 'routes', 'government.js'));
282
+ const real = board.proposeCharter;
283
+ board.proposeCharter = async (a) => ({ ok: false, reason: 'from_board', seen: a.form });
284
+ try {
285
+ assert.deepEqual(await governmentPort.proposeCharter({ form: 'monarchy', proposedBy: '42' }), { ok: false, reason: 'from_board', seen: 'monarchy' });
286
+ } finally { board.proposeCharter = real; }
276
287
  const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
277
288
  assert.match(pins, /'POST \/government\/board\/charter': 'perm:board\.item\.open'/);
278
289
  });
@@ -55,6 +55,7 @@ const NON_GRANT_MIGRATIONS = new Set([
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
56
  'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
57
57
  'government_038_vote_cast_via.sql', // HOW a vote was cast: a ballot, or a stage-close confirm (task 1005121)
58
+ 'government_039_vote_cast_via_filed.sql', // a board of one's confirm given by filing (task 1005130)
58
59
  ]);
59
60
 
60
61
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -0,0 +1,153 @@
1
+ // tests/government_sole_member.mjs — a board of ONE decides by filing (task 1005130,
2
+ // part C; the ruling task 1005121 shipped for closing a genesis stage, applied
3
+ // wherever it holds).
4
+ //
5
+ // The rule held here, for a charter change, a laws change and a decision-rules change:
6
+ // • the sole member of a first_ratifier board files → their yes is cast in the same
7
+ // call, recorded as THEIR vote (attributed, cast_via 'filed'), and the sitting
8
+ // closes through the ordinary close path;
9
+ // • a second member on the board, a consent or majority rule, or a caller who is not
10
+ // the sole member → the filing comes back exactly as it was, and nothing is cast;
11
+ // • the yes goes through the real castVote — its membership re-check, and a
12
+ // 'filed' cast is refused on anything but an amendment, or as a 'no';
13
+ // • a filing the filer was refused comes back untouched, and a failing confirm
14
+ // leaves the sitting open rather than failing the filing.
15
+ // The real routes (permission gate first, a non-member's filing left open, a lower
16
+ // rank refused before anything is filed) are walked end to end in
17
+ // scripts/bongos/government-walk.js (rows G3, G5, B*, L2–L4, L11–L15).
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const GOV = path.join(ROOT, 'modules', 'government');
27
+ const db = require(path.join(GOV, 'db.js'));
28
+ const board = require(path.join(GOV, 'board.js'));
29
+ const sole = require(path.join(GOV, 'sole-member.js'));
30
+
31
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
32
+ const FILERS = ['proposeCharter', 'proposeLaws', 'proposeDecisionRules'];
33
+
34
+ // A world: who holds which rank, the one open item a filing opened, and every vote
35
+ // written. db is stubbed underneath the REAL castVote; the filers and the close are
36
+ // stubbed on board (looked up at call time), so this exercises the decision alone.
37
+ async function world({ members = ['42'], constitution = MONARCHY, subjectType = 'constitutional_amendment', filedOk = true } = {}, fn) {
38
+ const votes = [];
39
+ const closes = [];
40
+ const item = { id: 7, subject_type: subjectType, constitution, closed_at: null, expired: false };
41
+ const stubs = {
42
+ getActiveBuildersWithRank: async () => members.map((id) => ({ builder_id: id, github_login: `b${id}`, display_name: `B${id}` })),
43
+ builderHoldsRankActive: async (id) => members.includes(String(id)),
44
+ getBoardItemForVote: async () => ({ ...item }),
45
+ getBoardItemAuthor: async () => '42',
46
+ insertBoardVote: async (v) => { const row = { item_id: String(v.itemId), voter_id: String(v.voterId), direction: v.direction, cast_via: v.castVia || 'ballot', cast_at: '2026-11-13T12:00:00.000Z' }; votes.push(row); return row; },
47
+ getBoardVote: async () => null,
48
+ insertVoteKarma: async () => { throw new Error('a confirm given by filing earns no vote karma'); },
49
+ };
50
+ const boardStubs = {
51
+ maybeCloseAfterVote: async (id) => { closes.push(id); return { closed: true, outcome: 'passed', item: { id } }; },
52
+ };
53
+ for (const f of FILERS) boardStubs[f] = async () => (filedOk ? { ok: true, amendment: { id: 3 }, item: { ...item } } : { ok: false, reason: 'proposer_would_lose_their_seat' });
54
+ const saved = {};
55
+ for (const [k, v] of Object.entries(stubs)) { saved[`db.${k}`] = db[k]; db[k] = v; }
56
+ for (const [k, v] of Object.entries(boardStubs)) { saved[`board.${k}`] = board[k]; board[k] = v; }
57
+ try {
58
+ return await fn({ votes, closes });
59
+ } finally {
60
+ for (const [k, v] of Object.entries(saved)) {
61
+ const [obj, key] = k.split('.');
62
+ (obj === 'db' ? db : board)[key] = v;
63
+ }
64
+ }
65
+ }
66
+
67
+ for (const filer of FILERS) {
68
+ test(`${filer}: the sole member of a one-yes board decides by filing, and the vote is theirs on the record`, async () => {
69
+ await world({}, async ({ votes, closes }) => {
70
+ const out = await sole[filer]({ proposedBy: '42' });
71
+ assert.equal(out.ok, true);
72
+ assert.deepEqual(out.decided, { closed: true, outcome: 'passed', cast_via: 'filed' });
73
+ assert.equal(votes.length, 1);
74
+ assert.equal(votes[0].voter_id, '42', 'attributed to the member who filed');
75
+ assert.equal(votes[0].direction, 'yes');
76
+ assert.equal(votes[0].cast_via, 'filed', 'the record says how it was cast');
77
+ assert.ok(votes[0].cast_at, 'and when');
78
+ assert.deepEqual(closes, [7], 'closed through the ordinary close path');
79
+ });
80
+ });
81
+
82
+ test(`${filer}: a second member on the board — nothing is cast, the sitting waits`, async () => {
83
+ await world({ members: ['42', '43'] }, async ({ votes, closes }) => {
84
+ const out = await sole[filer]({ proposedBy: '42' });
85
+ assert.equal(out.decided, undefined);
86
+ assert.deepEqual(votes, []);
87
+ assert.deepEqual(closes, []);
88
+ });
89
+ });
90
+
91
+ for (const rule of ['consent', 'majority', 'unanimous']) {
92
+ test(`${filer}: under ${rule} a sole member's filing still waits for their vote`, async () => {
93
+ await world({ constitution: { ...MONARCHY, pass_rule: rule } }, async ({ votes, closes }) => {
94
+ const out = await sole[filer]({ proposedBy: '42' });
95
+ assert.equal(out.decided, undefined);
96
+ assert.deepEqual(votes, []);
97
+ assert.deepEqual(closes, []);
98
+ });
99
+ });
100
+ }
101
+
102
+ test(`${filer}: a caller who is not the sole member — the sitting is left open, exactly as today`, async () => {
103
+ await world({ members: ['42'] }, async ({ votes, closes }) => {
104
+ const out = await sole[filer]({ proposedBy: '43' });
105
+ assert.equal(out.ok, true);
106
+ assert.equal(out.decided, undefined);
107
+ assert.deepEqual(votes, []);
108
+ assert.deepEqual(closes, []);
109
+ });
110
+ });
111
+
112
+ test(`${filer}: a refused filing comes back untouched, and nothing is cast`, async () => {
113
+ await world({ filedOk: false }, async ({ votes }) => {
114
+ assert.deepEqual(await sole[filer]({ proposedBy: '42' }), { ok: false, reason: 'proposer_would_lose_their_seat' });
115
+ assert.deepEqual(votes, []);
116
+ });
117
+ });
118
+ }
119
+
120
+ test('the yes goes through the real castVote: membership is re-checked under the item\'s snapshot', async () => {
121
+ // resolve says 42 is the one member, but the per-request re-check says 42 no longer
122
+ // holds the rank (demoted between the two reads): the cast is refused, nothing closes.
123
+ await world({}, async ({ votes, closes }) => {
124
+ db.builderHoldsRankActive = async () => false;
125
+ const out = await sole.proposeLaws({ proposedBy: '42' });
126
+ assert.equal(out.decided, undefined);
127
+ assert.deepEqual(votes, []);
128
+ assert.deepEqual(closes, []);
129
+ });
130
+ });
131
+
132
+ test('a cast marked "filed" is refused on anything but an amendment, and as a no', async () => {
133
+ await world({ subjectType: 'full_idea' }, async ({ votes }) => {
134
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'filed' })).reason, 'bad_cast_via');
135
+ assert.deepEqual(votes, []);
136
+ });
137
+ await world({ subjectType: 'genesis_stage' }, async () => {
138
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'filed' })).reason, 'bad_cast_via');
139
+ });
140
+ await world({}, async () => {
141
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'no', reasonMd: 'no', sectionKey: 'purpose', castVia: 'filed' })).reason, 'bad_cast_via');
142
+ assert.equal((await board.castVote({ itemId: 7, voterId: '42', direction: 'yes', castVia: 'by_magic' })).reason, 'bad_cast_via');
143
+ });
144
+ });
145
+
146
+ test('a confirm that fails leaves the filing standing and the sitting open', async () => {
147
+ await world({}, async () => {
148
+ board.maybeCloseAfterVote = async () => { throw new Error('db down'); };
149
+ const out = await sole.proposeCharter({ proposedBy: '42' });
150
+ assert.equal(out.ok, true);
151
+ assert.equal(out.decided, undefined);
152
+ });
153
+ });