@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
@@ -0,0 +1,322 @@
1
+ // tests/guide_handoff.mjs — the start-a-project guide's hand-off (task 1005274, goal 1000106)
2
+ //
3
+ // A founder who talked with the guide is never asked the same thing twice. Proven end to
4
+ // end, piece by piece, with no AI call, no real repository and no real compute:
5
+ // 1. the creation page fills in what the plan covers — and only what is still empty —
6
+ // RUN from projects.html's own script in a vm under a small DOM stub;
7
+ // 2. the create route carries the CALLER's own plan onto the new project, and the
8
+ // companion line takes it to the instance (the provisioning test seams: stubbed
9
+ // createInstance / enqueueIntent / pool, as tests/wizard_ship_mode.mjs does);
10
+ // 3. the instance writes the scope answers as carried over through the if-empty write,
11
+ // never over a founder's own answer, and serves the hand-off file at the top of the
12
+ // context every planning session reads (the real genesis routes, in-memory store);
13
+ // 4. the hand-off file says what the project is, the plan, the modules and the first steps;
14
+ // 5. a visitor who never used the guide: nothing is read, stamped or written.
15
+ //
16
+ // Run: node tests/guide_handoff.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { test, after } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { memStore } from './genesis_work.mjs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
30
+
31
+ const H = require('../modules/front-door-guide/handoff.js');
32
+ const provHandoff = require('../modules/provisioning/guide-handoff.js');
33
+ const instHandoff = require('../modules/onboarding/guide-handoff.js');
34
+ const gw = require('../modules/onboarding/genesis-work.js');
35
+
36
+ const PLAN = {
37
+ summary: 'A cooperative board game about running a lighthouse, for families who play on weekends.',
38
+ starting_point: 'fresh', what_you_have: [], project_type: 'game',
39
+ how_it_works: ['Players share one lighthouse and take turns', 'Storm cards raise the stakes each round'],
40
+ modules: [{ key: 'discord', label: 'Discord', summary: 'A chat home for your team.', why: 'Playtesters can talk between sessions.' }],
41
+ steps: [{ title: 'Write the one-page rules', detail: 'Enough to play a first round at the kitchen table.' }, { title: 'Run a first playtest', detail: 'Four people, one hour.' }],
42
+ needs: [{ item: 'Playtesters', detail: 'Four people for an hour.' }],
43
+ open_questions: ['How long should one game last?'],
44
+ cost_and_time: 'This plan does not estimate cost or time.',
45
+ };
46
+
47
+ // ── 4. the hand-off file and the explicit scope map ────────────────────────────
48
+
49
+ test('the scope map answers only what the plan says, and leaves every other question empty', () => {
50
+ assert.deepEqual(H.scopeAnswers(PLAN), { what_creating: PLAN.summary });
51
+ const scopeIds = gw.QUESTIONS.scope.map((q) => q.id).sort();
52
+ assert.deepEqual(Object.keys(H.SCOPE_MAP).sort(), scopeIds, 'every scope question is mapped, explicitly');
53
+ assert.deepEqual(Object.entries(H.SCOPE_MAP).filter(([, f]) => f).map(([q]) => q), ['what_creating'], 'one question is carried; five stay empty');
54
+ assert.deepEqual(H.scopeAnswers(null), {});
55
+ assert.deepEqual(H.scopeAnswers({ ...PLAN, summary: ' ' }), {}, 'an empty summary carries nothing');
56
+ });
57
+
58
+ test('the hand-off file says what the project is, the plan, the modules, the first steps and the next one', () => {
59
+ const md = H.handoffMarkdown(PLAN, { name: 'Lamplight' });
60
+ assert.match(md, /^# Lamplight: where this project started/);
61
+ assert.match(md, /## What the project is\n\nA cooperative board game about running a lighthouse/);
62
+ assert.match(md, /\*\*Kind of project:\*\* A game/);
63
+ assert.match(md, /## The plan\n\n- Players share one lighthouse/);
64
+ assert.match(md, /## Recommended modules\n\n- \*\*Discord\*\* \(`discord`\): A chat home for your team\. Why: Playtesters/);
65
+ assert.match(md, /## First steps\n\n1\. \*\*Write the one-page rules\*\* Enough to play/);
66
+ assert.match(md, /2\. \*\*Run a first playtest\*\*/);
67
+ assert.match(md, /## Your next step\n\nStart with step 1, "Write the one-page rules"\./);
68
+ assert.match(md, /If you are an AI session working on this project: read this first/, 'it tells the founder\'s AI how to treat it');
69
+ assert.match(md, /never ask them again what is already written here/);
70
+ assert.doesNotMatch(md, /undefined|null|\[object/);
71
+ assert.equal(H.handoffMarkdown(null), '');
72
+ assert.equal(H.handoffFor({ ...PLAN, summary: '' }), null, 'no plan worth handing off, no file');
73
+ const huge = H.handoffMarkdown({ ...PLAN, how_it_works: Array.from({ length: 400 }, (_, i) => `step ${i} `.repeat(10)) });
74
+ assert.ok(huge.length <= H.MARKDOWN_MAX, 'bounded, since it rides an env line');
75
+ });
76
+
77
+ // ── 2. the hub: the create route and the companion line ────────────────────────
78
+
79
+ const api = require('../src/module-api.js');
80
+ const provisioning = require('../modules/provisioning/provisioning.js');
81
+ const birth = require('../modules/provisioning/birth.js');
82
+ provisioning.getInstanceBySlug = async () => null;
83
+ provisioning.getInstanceByDomain = async () => null;
84
+ birth.stampBirth = async () => {};
85
+ provisioning.createInstance = async (_db, args) => ({
86
+ instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'byo-host', detail: {} }, created: true,
87
+ });
88
+ provisioning.enqueueIntent = async (_db, _id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
89
+ provisioning.getOpenIntent = async () => null;
90
+ provisioning.recordEvent = async () => {};
91
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
92
+ const stamped = [];
93
+ api.pool.query = async (sql, params) => {
94
+ if (/jsonb_build_object/.test(sql) && params[1] === provHandoff.KEY) stamped.push({ id: params[0], value: JSON.parse(params[2]) });
95
+ return { rows: [] };
96
+ };
97
+ const asked = [];
98
+ let portAnswer = null;
99
+ api.registerProvider('front-door-guide.handoff', async (builderId, opts) => { asked.push({ builderId, opts }); return portAnswer; });
100
+
101
+ const express = require('express');
102
+ const app = express();
103
+ app.use(express.json());
104
+ app.use((_req, res, next) => {
105
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code } });
106
+ next();
107
+ });
108
+ app.use('/api/bongos', require('../modules/provisioning/routes/provisioning.js')());
109
+ const server = app.listen(0);
110
+ after(() => server.close());
111
+ async function create(body) {
112
+ await new Promise((r) => (server.listening ? r() : server.once('listening', r)));
113
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/api/bongos/provisioning/instances`, {
114
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
115
+ });
116
+ return { status: res.status, body: await res.json() };
117
+ }
118
+
119
+ test('from_guide: the create route stamps the CALLER\'s own plan on the row it made', async () => {
120
+ stamped.length = 0; asked.length = 0;
121
+ portAnswer = H.handoffFor(PLAN, { name: 'lamplight' });
122
+ const r = await create({ slug: 'lamplight', hosting_shape: 'byo-host', from_guide: true });
123
+ assert.equal(r.status, 201, JSON.stringify(r.body));
124
+ assert.deepEqual(asked, [{ builderId: 7, opts: { name: 'lamplight' } }], 'asked for the signed-in builder, never a plan the request names');
125
+ assert.equal(stamped.length, 1);
126
+ assert.equal(stamped[0].id, 41);
127
+ assert.deepEqual(stamped[0].value.scope, { what_creating: PLAN.summary });
128
+ assert.match(stamped[0].value.markdown, /## First steps/);
129
+ assert.equal((await create({ slug: 'x2', hosting_shape: 'byo-host', from_guide: 'yes' })).status, 400, 'a strict boolean');
130
+ });
131
+
132
+ test('the no-guide path: no flag, or a builder with no plan, stamps nothing', async () => {
133
+ stamped.length = 0; asked.length = 0;
134
+ portAnswer = H.handoffFor(PLAN);
135
+ assert.equal((await create({ slug: 'plain', hosting_shape: 'byo-host' })).status, 201);
136
+ assert.deepEqual(asked, [], 'without the flag the guide is not even asked');
137
+ portAnswer = null;
138
+ assert.equal((await create({ slug: 'noplan', hosting_shape: 'byo-host', from_guide: true })).status, 201, 'no plan never fails a create');
139
+ assert.deepEqual(stamped, []);
140
+ assert.equal(await provHandoff.stampGuideHandoff({ query: () => assert.fail('no write') }, 1, 7, { resolve: () => null }), false, 'no guide module: nothing');
141
+ });
142
+
143
+ test('the companion line carries the stamp to the instance, and is empty without one', () => {
144
+ const row = { settings: { [provHandoff.KEY]: H.handoffFor(PLAN) } };
145
+ const line = provisioning.SETTINGS_COMPANION_ENV.GUIDE_HANDOFF(row);
146
+ assert.match(line, /^[A-Za-z0-9_-]+$/, 'one env-safe line');
147
+ const back = instHandoff.handoffMarker({ guideHandoff: line });
148
+ assert.deepEqual(back.scope, { what_creating: PLAN.summary });
149
+ assert.equal(back.markdown, H.handoffFor(PLAN).markdown);
150
+ assert.equal(provisioning.SETTINGS_COMPANION_ENV.GUIDE_HANDOFF({ settings: {} }), '');
151
+ assert.equal(provisioning.SETTINGS_COMPANION_ENV.GUIDE_HANDOFF({ settings: { [provHandoff.KEY]: { markdown: '' } } }), '');
152
+ assert.equal(instHandoff.handoffMarker({ guideHandoff: 'not-json' }), null);
153
+ assert.equal(instHandoff.handoffMarker({}), null);
154
+ });
155
+
156
+ // ── 3. the instance: carried-over scope answers and the file in the context ────
157
+
158
+ function settingsStore() {
159
+ const rows = new Map();
160
+ return {
161
+ rows,
162
+ getSetting: async (k) => (rows.has(k) ? { key: k, value: rows.get(k) } : null),
163
+ setSetting: async (k, v) => { rows.set(k, String(v)); return { key: k, value: String(v) }; },
164
+ };
165
+ }
166
+ const MARKER = instHandoff.handoffMarker({ guideHandoff: provisioning.SETTINGS_COMPANION_ENV.GUIDE_HANDOFF({ settings: { [provHandoff.KEY]: H.handoffFor(PLAN) } }) });
167
+
168
+ test('Genesis scope opens with the guide\'s answer, carried over and editable; the rest stay empty', async () => {
169
+ const store = memStore();
170
+ const s = settingsStore();
171
+ const deps = { store, marker: MARKER, ...s };
172
+ const r = await instHandoff.carryOver(deps);
173
+ assert.deepEqual(r, { ran: true, carried: ['what_creating'], kept: [] });
174
+ const scope = (await gw.readStage('scope', deps)).work;
175
+ const q = scope.questions.find((x) => x.id === 'what_creating');
176
+ assert.equal(q.answer.text, PLAN.summary);
177
+ assert.equal(q.answer.origin, 'carried_over');
178
+ assert.equal(q.answer.carried_over, true, 'shown as carried over');
179
+ assert.equal(scope.counts.empty, 5, 'nothing invented for the other five');
180
+ // editable: the founder's edit makes it theirs
181
+ await gw.setAnswer('scope', 'what_creating', { text: 'A lighthouse game for my nieces.' }, 1, deps);
182
+ const mine = (await gw.readStage('scope', deps)).work.questions.find((x) => x.id === 'what_creating').answer;
183
+ assert.equal(mine.origin, 'founder');
184
+ assert.equal(mine.carried_over, false);
185
+ assert.deepEqual(await instHandoff.carryOver(deps), { ran: false, reason: 'already' }, 'once');
186
+ });
187
+
188
+ test('a founder\'s own answer is never overwritten by the carry-over', async () => {
189
+ const store = memStore();
190
+ const s = settingsStore();
191
+ const deps = { store, marker: MARKER, ...s };
192
+ await gw.setAnswer('scope', 'what_creating', { text: 'My own words, typed first.' }, 1, deps);
193
+ const historyBefore = store.history.length;
194
+ const r = await instHandoff.carryOver(deps);
195
+ assert.deepEqual(r, { ran: true, carried: [], kept: ['what_creating'] });
196
+ const a = (await gw.readStage('scope', deps)).work.questions.find((x) => x.id === 'what_creating').answer;
197
+ assert.equal(a.text, 'My own words, typed first.');
198
+ assert.equal(a.origin, 'founder');
199
+ assert.equal(store.history.length, historyBefore, 'no history row: nothing was written over it');
200
+ assert.equal(s.rows.get(instHandoff.HANDOFF_KEY), MARKER.markdown, 'the file still lands');
201
+ });
202
+
203
+ test('the hand-off file leads the context a planning session reads, over the real routes', async () => {
204
+ const store = memStore();
205
+ const s = settingsStore();
206
+ const mount = require('../modules/onboarding/routes/genesis-work.js');
207
+ const a = express();
208
+ a.use(express.json());
209
+ const router = express.Router();
210
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 1 }; next(); };
211
+ mount(router, { store, marker: MARKER, fresh: true, ...s });
212
+ a.use('/api/bongos', router);
213
+ const srv = a.listen(0);
214
+ try {
215
+ const base = `http://127.0.0.1:${srv.address().port}/api/bongos`;
216
+ const scope = await (await fetch(`${base}/founding/work/scope`)).json();
217
+ assert.equal(scope.work.questions.find((x) => x.id === 'what_creating').answer.origin, 'carried_over', 'opening the interview lands the hand-off');
218
+ const ctx = await (await fetch(`${base}/founding/context`)).json();
219
+ assert.equal(ctx.handoff, MARKER.markdown);
220
+ assert.ok(ctx.markdown.startsWith('# lamplight: where this project started') || ctx.markdown.startsWith('# '), 'the file first');
221
+ assert.ok(ctx.markdown.indexOf('## First steps') < ctx.markdown.indexOf('# What the founder has said'), 'then the founder\'s own answers');
222
+ assert.match(ctx.markdown, /carried over from before sign-up/);
223
+ } finally { srv.close(); }
224
+ });
225
+
226
+ test('the no-guide path on the instance: no marker, no database touched', async () => {
227
+ const deps = {
228
+ marker: null,
229
+ store: { loadAll: () => assert.fail('no read'), writeAnswer: () => assert.fail('no write') },
230
+ getSetting: () => assert.fail('no settings read'), setSetting: () => assert.fail('no settings write'),
231
+ };
232
+ assert.deepEqual(await instHandoff.carryOver(deps), { ran: false, reason: 'no_handoff' });
233
+ assert.equal(await instHandoff.readHandoff(deps), null);
234
+ assert.deepEqual(instHandoff.withHandoff({ markdown: 'x', stages: [] }, null), { markdown: 'x', stages: [], handoff: null });
235
+ });
236
+
237
+ // ── 1. the creation page's pre-fill, RUN from projects.html ───────────────────
238
+
239
+ const START = ' /* ── the guide\'s answers (task 1005274) ──';
240
+ const END = '\n paintGuideFill();\n';
241
+ function region() {
242
+ const a = HUB.indexOf(START);
243
+ const b = HUB.indexOf(END, a);
244
+ assert.ok(a > 0 && b > a, 'the pre-fill region is where this test slices it');
245
+ return HUB.slice(a, b + END.length);
246
+ }
247
+
248
+ function page({ chat, plan, enabled = true, signedIn = true, state: st }) {
249
+ const els = {
250
+ guideFill: { hidden: true }, guideFileLink: { href: '' }, 'f-desc': { value: (st && st.description) || '' },
251
+ };
252
+ const calls = [];
253
+ const store = new Map(chat ? [['cb-front-door-guide', JSON.stringify(chat)]] : []);
254
+ const painted = [];
255
+ const ctx = {
256
+ window: { __MODULES__: { enabled: { 'front-door-guide': enabled } } },
257
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, v), removeItem: (k) => store.delete(k) },
258
+ fetch: async (url, opts) => {
259
+ calls.push({ url, method: (opts && opts.method) || 'GET', body: opts && opts.body });
260
+ if (/\/attach$/.test(url)) return { ok: true, json: async () => ({ ok: true }) };
261
+ return { ok: true, json: async () => ({ ok: true, conversation: plan ? { plan } : null }) };
262
+ },
263
+ API: '/api/bongos',
264
+ me: signedIn ? { id: 7 } : null,
265
+ state: { name: 'Lamplight', projType: null, description: '', modules: null, ...st },
266
+ root: { querySelector: (sel) => (/data-type="(game|research|business|non-profit|not-sure)"/.test(sel) ? {} : null) },
267
+ $: (id) => els[id],
268
+ saveDraft: () => painted.push('draft'), paintType: () => painted.push('type'), renderModules: () => painted.push('modules'),
269
+ paintSetup: () => {}, paintCreate: () => {}, encodeURIComponent, JSON, Array, String,
270
+ };
271
+ vm.createContext(ctx);
272
+ vm.runInContext(`${region()}\nthis.guidePrefill = guidePrefill;`, ctx);
273
+ return { ctx, els, calls, store, painted };
274
+ }
275
+ const settle = () => new Promise((r) => setTimeout(r, 20));
276
+
277
+ test('creation opens with the guide\'s answers filled in, and the chat moves onto the account', async () => {
278
+ const p = page({ chat: { id: '12', token: 'tok_abcdefghijklmnop' }, plan: PLAN });
279
+ p.ctx.guidePrefill();
280
+ await settle();
281
+ assert.deepEqual(p.calls.map((c) => `${c.method} ${c.url}`), ['POST /api/bongos/front-door-guide/conversations/12/attach', 'GET /api/bongos/front-door-guide/mine']);
282
+ assert.deepEqual(JSON.parse(p.calls[0].body), { token: 'tok_abcdefghijklmnop' });
283
+ assert.equal(p.ctx.state.projType, 'game');
284
+ assert.equal(p.ctx.state.description, PLAN.summary);
285
+ assert.equal(p.els['f-desc'].value, PLAN.summary, 'shown in the ordinary, editable field');
286
+ assert.deepEqual([...p.ctx.state.modules], ['discord']);
287
+ assert.equal(p.ctx.state.name, 'Lamplight', 'the name stays the founder\'s: the plan has none');
288
+ assert.equal(p.ctx.state.fromGuide, true, 'the create will say from_guide');
289
+ assert.equal(p.els.guideFill.hidden, false, 'the page says where the answers came from');
290
+ assert.equal(p.els.guideFileLink.href, '/api/bongos/front-door-guide/mine/handoff.md?name=Lamplight', 'and offers the hand-off file');
291
+ p.ctx.guidePrefill();
292
+ await settle();
293
+ assert.equal(p.calls.length, 2, 'asked once per page');
294
+ });
295
+
296
+ test('pre-fill never overwrites an answer the founder already gave', async () => {
297
+ const p = page({ chat: { id: '12', token: 'tok_abcdefghijklmnop' }, plan: PLAN, state: { projType: 'research', description: 'Mine.', modules: [] } });
298
+ p.ctx.guidePrefill();
299
+ await settle();
300
+ assert.equal(p.ctx.state.projType, 'research');
301
+ assert.equal(p.ctx.state.description, 'Mine.');
302
+ assert.deepEqual([...p.ctx.state.modules], [], '"none of them" is an answer too');
303
+ });
304
+
305
+ test('the no-guide path on the page: no chat in this browser, guide off, or signed out → nothing read, nothing changed', async () => {
306
+ for (const opts of [{ chat: null, plan: PLAN }, { chat: { id: '1', token: 'tok_abcdefghijklmnop' }, plan: PLAN, enabled: false }, { chat: { id: '1', token: 'tok_abcdefghijklmnop' }, plan: PLAN, signedIn: false }]) {
307
+ const p = page(opts);
308
+ const before = JSON.stringify(p.ctx.state);
309
+ p.ctx.guidePrefill();
310
+ await settle();
311
+ assert.deepEqual(p.calls, []);
312
+ assert.equal(JSON.stringify(p.ctx.state), before);
313
+ assert.equal(p.els.guideFill.hidden, true);
314
+ }
315
+ });
316
+
317
+ test('the create request carries from_guide only for a page that filled itself in, and a made project forgets the chat', () => {
318
+ assert.match(HUB, /from_guide: state\.fromGuide \? true : undefined,/);
319
+ assert.match(HUB, /if \(state\.fromGuide\) localStorage\.removeItem\(GUIDE_KEY\);/);
320
+ assert.match(HUB, /\n {6}guidePrefill\(\);\n {6}if \(currentView === 'new'\) enter\(false\);/, 'it runs once the sign-in settles');
321
+ assert.match(HUB, /<p class="fillOffer hint" id="guideFill" hidden>/);
322
+ });
@@ -628,4 +628,56 @@ await test('the ideator\'s ladder ranks ideas, votes and work; the builder\'s la
628
628
  Draw.ladderFor(m, i, FIXTURE.viewer.id).forEach((r) => assert.deepEqual(Object.keys(r.v), ['art', 'build', 'idea', 'gov']));
629
629
  });
630
630
 
631
+ // task 1005112: Genesis is the founding of the government district (V3). While a
632
+ // project is founding, a district building stands only once the stage raising it is done.
633
+ await test('the city payload carries the unraised district buildings, from the onboarding port\'s answer', async () => {
634
+ const fm = require(path.join(ROOT, 'modules/onboarding/founding-mode.js'));
635
+ const h = city.homeCityHandler({ read: async () => raw(), founding: async () => ({ stage: 'charter', unbuilt: fm.shapeFoundingMode({ active: 'true', stage: 'charter' }).unbuilt }) });
636
+ const res = fakeRes();
637
+ await h({ builder: { id: '1' }, query: {} }, res);
638
+ assert.equal(res.body.founding.stage, 'charter');
639
+ assert.deepEqual(Object.keys(res.body.founding.unbuilt).sort(), ['automation', 'charter', 'disputes', 'people', 'review', 'rules', 'treasury']);
640
+ // The default reader asks the real port through the doorway; not founding is null.
641
+ const seams = require(path.join(ROOT, 'src/module-seams.js'));
642
+ seams._reset();
643
+ try {
644
+ const plain = fakeRes();
645
+ await city.homeCityHandler({ read: async () => raw() })({ builder: { id: '1' }, query: {} }, plain);
646
+ assert.equal(plain.body.founding, null, 'no onboarding module: the district is drawn whole');
647
+ seams.registerProvider('onboarding', { foundingModeFor: async () => fm.shapeFoundingMode({ active: 'true', stage: 'scope' }) });
648
+ const on = fakeRes();
649
+ await city.homeCityHandler({ read: async () => raw() })({ builder: { id: '1' }, query: {} }, on);
650
+ assert.equal(on.body.founding.unbuilt.planning, 'scope', 'at scope, Planning is still to rise');
651
+ seams._reset();
652
+ seams.registerProvider('onboarding', { foundingModeFor: async () => { throw new Error('x'); } });
653
+ const bad = fakeRes();
654
+ await city.homeCityHandler({ read: async () => raw() })({ builder: { id: '1' }, query: {} }, bad);
655
+ assert.equal(bad.body.founding, null, 'a failed read draws the district whole, never a broken city');
656
+ } finally { seams._reset(); }
657
+ });
658
+
659
+ await test('an unraised district building is a marked-out foundation, and the district says how many stand', () => {
660
+ const m = Draw.cityModel({ ...FIXTURE, founding: { stage: 'strategize', unbuilt: { release: 'strategize', charter: 'charter', automation: 'alive' } } });
661
+ const core = linksOf(all(Draw.citySvg(m, m.W - 1, { mode: 'dark', gov: Gov }).nodes)).find((n) => /\bhc-core\b/.test(n.attrs.class));
662
+ const found = all([core]).filter((n) => /hc-foundation/.test(n.attrs.class || '')).map((n) => n.attrs['data-building']).sort();
663
+ assert.deepEqual(found, ['automation', 'charter', 'release']);
664
+ assert.match(core.attrs['aria-label'], /6 of nine buildings raised so far/);
665
+ assert.match(core.attrs['data-tip-body'], /6 of nine buildings raised so far/, 'the hover caption says the same as the screen reader');
666
+ // The Charter's own faces are not drawn while it is a foundation.
667
+ const k = Gov.isoKit(50, 25, 0, 0, 0.82, Gov.isoTheme('dark'), true), charter = [];
668
+ Gov.isoInstitution(charter, k, 'charter', 0, 0, {});
669
+ const whole = linksOf(all(Draw.citySvg(Draw.cityModel(FIXTURE), m.W - 1, { mode: 'dark', gov: Gov }).nodes)).find((n) => /\bhc-core\b/.test(n.attrs.class));
670
+ assert.ok(all([core]).filter((n) => n.tag === 'path').length < all([whole]).filter((n) => n.tag === 'path').length);
671
+ assert.match(whole.attrs['aria-label'], /nine buildings, one for each part/, 'not founding: the district as before');
672
+ });
673
+
674
+ await test('every district building is raised by exactly one genesis stage, or by coming alive', () => {
675
+ const fm = require(path.join(ROOT, 'modules/onboarding/founding-mode.js'));
676
+ const raised = [...Object.values(fm.BUILDS).flat(), ...fm.BUILDS_WHEN_ALIVE];
677
+ assert.deepEqual([...raised].sort(), Gov.CIVIC_SLOTS.map((s) => s[0]).sort(), 'the nine slots, each once');
678
+ for (const stage of ['scope', 'strategize', 'charter', 'legalize', 'legislation']) assert.ok(fm.BUILDS[stage].length > 0, `${stage} raises something`);
679
+ assert.equal(Object.keys(fm.shapeFoundingMode({ active: 'true', stage: 'decide' }).unbuilt).length, 9, 'deciding: the plaza is bare');
680
+ assert.deepEqual(Object.keys(fm.shapeFoundingMode({ active: 'true', stage: 'legislation' }).unbuilt).sort(), ['automation', 'disputes', 'review', 'rules']);
681
+ });
682
+
631
683
  summary();
@@ -103,7 +103,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
103
103
  // task 1005070: the remembered nav (the rail paints before /me).
104
104
  // task 1005088: the sidebar mark is drawn by reference to the one brand mark.
105
105
  // task 1005144: My agents, Project settings, Permissions and Modules left for the options menu.
106
- const SHELL_SHA = '89f6f166ed22a8ee';
106
+ const SHELL_SHA = 'ccb3c6fe310ee6a2';
107
107
 
108
108
  test('shell.js has not changed without its cache-bust being bumped', () => {
109
109
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -50,6 +50,8 @@ let published = [];
50
50
  let healed = [];
51
51
 
52
52
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
53
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
54
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
53
55
  provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
54
56
  provisioning.getInstanceByDomain = async () => null;
55
57
  provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
@@ -81,6 +81,7 @@ ${NEUTRAL_PREFIX}_DEMO_PEOPLE=
81
81
  ${NEUTRAL_PREFIX}_DEMO_HOURS=
82
82
  ${NEUTRAL_PREFIX}_SHIP_MODE=
83
83
  ${NEUTRAL_PREFIX}_TEST_PROJECT=
84
+ ${NEUTRAL_PREFIX}_GUIDE_HANDOFF=
84
85
  ${NEUTRAL_PREFIX}_HUB_UPGRADE_URL=
85
86
  `;
86
87
 
@@ -152,7 +153,7 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
152
153
  // the birth marker and a demo's box (task 1004506), empty on an un-stamped row
153
154
  [`${NEUTRAL_PREFIX}_FOUNDING_BIRTH`]: '', [`${NEUTRAL_PREFIX}_FOUNDING_GENESIS`]: '', [`${NEUTRAL_PREFIX}_DEMO_PEOPLE`]: '', [`${NEUTRAL_PREFIX}_DEMO_HOURS`]: '',
154
155
  // the wizard's ship mode (task 1004629), empty when none was picked
155
- [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '',
156
+ [`${NEUTRAL_PREFIX}_SHIP_MODE`]: '', [`${NEUTRAL_PREFIX}_TEST_PROJECT`]: '', [`${NEUTRAL_PREFIX}_GUIDE_HANDOFF`]: '',
156
157
  // where the hall's Update button goes (task 1004608), empty when no hub origin resolves
157
158
  [`${NEUTRAL_PREFIX}_HUB_UPGRADE_URL`]: '',
158
159
  });
@@ -235,6 +235,10 @@ console.log('\nthe routes:');
235
235
 
236
236
  let instance = null;
237
237
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
238
+ // The routes also admit an owner-made grant (task 1004161) and audit each change: nobody here
239
+ // holds a grant, and the audit rows are not this file's subject.
240
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
241
+ provisioning.recordEvent = async () => {};
238
242
 
239
243
  const api = require('../src/module-api.js');
240
244
  let builder = { id: 7, rank: 'xenos', github_login: 'owner' };
@@ -32,6 +32,8 @@ const express = require('express');
32
32
 
33
33
  const api = require('../src/module-api.js');
34
34
  const provisioning = require('../modules/provisioning/provisioning.js');
35
+ // The gate also admits an owner-made grant (task 1004161, ADR 0407); nobody in this file holds one.
36
+ require('../modules/provisioning/grants.js').liveGrant = async () => null;
35
37
  const envManifest = require('../modules/provisioning/env-manifest.js');
36
38
  const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
37
39