@bongos/core 1.19.533 → 1.19.535

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 (62) hide show
  1. package/.bongos-core.json +121 -56
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +162 -157
  11. package/docs/copy-registry.json +252 -196
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +16 -0
  14. package/modules/hall-ui/public/hall-kit.js +93 -7
  15. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  16. package/modules/hall-ui/public/roadmap.html +7 -1
  17. package/modules/hall-ui/public/roadmap.js +16 -0
  18. package/modules/hall-ui/public/style.css +4 -2
  19. package/modules/hall-ui/public/watch.html +4 -0
  20. package/modules/hall-ui/public/watch.js +116 -0
  21. package/modules/onboarding/module.json +1 -1
  22. package/modules/onboarding/routes/access-requests.js +21 -6
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-catalog.js +202 -0
  25. package/modules/platform-identity/routes/my-projects.js +7 -2
  26. package/modules/platform-identity/routes/projects.js +6 -0
  27. package/modules/platform-identity/routes/sso.js +10 -0
  28. package/modules/provisioning/catalog-bridge.js +105 -0
  29. package/modules/provisioning/provisioning.js +28 -2
  30. package/modules/provisioning/routes/provisioning.js +45 -11
  31. package/modules/provisioning/tests/provisioning.mjs +11 -8
  32. package/modules/public-landing/public/assets/cosmos.css +15 -0
  33. package/modules/public-landing/public/projects.html +162 -3
  34. package/modules/ui-design/kit/serve.js +1 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness.js +9 -0
  38. package/scripts/gds/provision-net.js +7 -0
  39. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  40. package/src/bongos/project-door.js +138 -0
  41. package/src/branding.js +8 -0
  42. package/src/module-api.js +12 -1
  43. package/tests/access_requests_invite.mjs +2 -2
  44. package/tests/application_lifecycle.mjs +1 -0
  45. package/tests/catalog_only_client.mjs +242 -0
  46. package/tests/currency_label.mjs +8 -5
  47. package/tests/hall_kit_charts.mjs +173 -0
  48. package/tests/hall_oversight_world.mjs +159 -0
  49. package/tests/hall_reading_world.mjs +153 -0
  50. package/tests/module_api.mjs +1 -0
  51. package/tests/project_catalog_bridge.mjs +355 -0
  52. package/tests/project_catalog_routes.mjs +213 -0
  53. package/tests/project_join_door.mjs +166 -0
  54. package/tests/project_join_door_route.mjs +206 -0
  55. package/tests/project_modules_ui.mjs +318 -0
  56. package/tests/projects_hub.mjs +6 -1
  57. package/tests/projects_hub_pre_uat.mjs +2 -2
  58. package/tests/provision_settings_apply.mjs +6 -5
  59. package/tests/provisioning_module_selection.mjs +164 -3
  60. package/tests/provisioning_settings.mjs +10 -7
  61. package/tests/provisioning_settings_apply.mjs +3 -3
  62. package/tests/provisioning_settings_env.mjs +7 -4
@@ -0,0 +1,318 @@
1
+ // tests/project_modules_ui.mjs — adding and removing a project's extras AFTER
2
+ // creation, EXECUTED (BV1.R23, task 1002342, ADR 0245).
3
+ //
4
+ // tests/provisioning_module_selection.mjs pins the ROUTE half of "add modules
5
+ // later"; this file pins the surface an owner actually uses. It lifts the manage
6
+ // page's extras region out of modules/public-landing/public/projects.html —
7
+ // ensureModsCatalog, modsSelected, renderMods, doModToggle and the delegated
8
+ // click — runs it in a vm under a minimal DOM stub, PARSES the markup it writes
9
+ // into innerHTML, and drives a real click on a toggle.
10
+ //
11
+ // The precedent is tests/project_door_ui.mjs beside it (there is no jsdom in this
12
+ // repo), including its rule that the slice boundaries are themselves asserted, so
13
+ // a rename can never leave this file quietly executing an empty string.
14
+ //
15
+ // What it proves, and why each matters:
16
+ // • the optional CATALOG renders — one row per module the server offers, with
17
+ // the owner-facing line, and nothing the server did not offer
18
+ // • the project's CURRENT set is what is switched on, resolved by the SERVER
19
+ // (a skipped picker reads as its bundle, and this page never re-derives that
20
+ // rule — the one way two surfaces name different modules for one project)
21
+ // • a toggle issues the write, as the WHOLE list in catalog order, on the
22
+ // own-scoped detail channel
23
+ // • the band repaints from the platform's answer, never from what was sent —
24
+ // and a refusal puts it back to what the row still says
25
+ // • the always-on core is stated and is not on the band at all
26
+ // • the done panel carries one pointer at this surface for the project just
27
+ // created, so a skipped picker is never a dead end
28
+ //
29
+ // Run: node --test --test-reporter=tap tests/project_modules_ui.mjs
30
+
31
+ import test from 'node:test';
32
+ import assert from 'node:assert/strict';
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import vm from 'node:vm';
36
+ import { fileURLToPath } from 'node:url';
37
+
38
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
39
+ const HUB = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'projects.html'), 'utf8')
40
+ .replace(/\r\n/g, '\n');
41
+ const SCRIPT = (HUB.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/) || [, ''])[1];
42
+
43
+ const START = SCRIPT.indexOf('var modsCatalog = null;');
44
+ const END = SCRIPT.indexOf('/* ── on the map (task 1002324)');
45
+ assert.ok(START > 0, 'the extras region must start at modsCatalog');
46
+ assert.ok(END > START, 'the extras region must end before the join-door section');
47
+ const REGION = SCRIPT.slice(START, END);
48
+ for (const fn of ['function ensureModsCatalog(', 'function renderMods(', 'function doModToggle(']) {
49
+ assert.ok(REGION.includes(fn), `the region must carry ${fn}`);
50
+ }
51
+
52
+ // ── the fixture catalog ──────────────────────────────────────────────────────
53
+ // A FIXTURE, not a live require: this file asserts the band reads the shape, and
54
+ // tests/provisioning_starter_bundles.mjs asserts the route emits it. Two files,
55
+ // one shape, neither taking the other's word for it (the module-picker rule).
56
+ const CATALOG = {
57
+ core: ['memory', 'lifecycle', 'hall-ui'],
58
+ optional: [
59
+ { key: 'dev-box', label: 'Dev box', summary: 'A shared machine your builders work on.' },
60
+ { key: 'discord', label: 'Discord', summary: 'Mirrors what ships into a Discord channel.' },
61
+ { key: 'agents', label: 'Agents', summary: 'Lets AI builders claim and ship work on their own.' },
62
+ ],
63
+ };
64
+
65
+ // ── the DOM stub ─────────────────────────────────────────────────────────────
66
+ // Narrow on purpose (the task_detail_ui.mjs rule): it supports exactly what the
67
+ // region uses, so it stays honest about what it can prove.
68
+
69
+ const ROW = /<button type="button" class="modOpt" data-mod="([^"]*)" aria-pressed="(true|false)"( disabled)?><b>([^<]*)<\/b><small>([^<]*)<\/small><\/button>/g;
70
+
71
+ function parseRows(html) {
72
+ return [...String(html).matchAll(ROW)].map((m) => ({
73
+ key: m[1], on: m[2] === 'true', disabled: !!m[3], label: m[4], summary: m[5],
74
+ }));
75
+ }
76
+
77
+ // Boot the region against a scripted catalog + detail channel and hand back the
78
+ // handles a test drives it with.
79
+ function boot({ selected = ['dev-box'], source = 'chosen', catalog = CATALOG, catalogOk = true, patchAnswer } = {}) {
80
+ const requests = [];
81
+ const els = new Map();
82
+ const listeners = new Map();
83
+
84
+ const makeEl = (id) => ({
85
+ _id: id, _html: '', textContent: '', className: '', disabled: false,
86
+ set innerHTML(v) { this._html = String(v); },
87
+ get innerHTML() { return this._html; },
88
+ addEventListener(type, fn) {
89
+ if (type !== 'click') return;
90
+ if (!listeners.has(id)) listeners.set(id, []);
91
+ listeners.get(id).push(fn);
92
+ },
93
+ });
94
+ const $ = (id) => {
95
+ if (!els.has(id)) els.set(id, makeEl(id));
96
+ return els.get(id);
97
+ };
98
+ const slotHtml = () => ($('mMods')._html || '');
99
+
100
+ // `_json` is what jsonOrEmpty hands back (the PATCH path); `json()` is what
101
+ // ensureModsCatalog calls directly (the GET path) — the region uses both.
102
+ const answer = (body, { ok = true, status = 200 } = {}) =>
103
+ Promise.resolve({ ok, status, _json: body, json: () => Promise.resolve(body) });
104
+ const fetch = (url, init = {}) => {
105
+ const method = init.method || 'GET';
106
+ requests.push({ url, method, body: init.body, credentials: init.credentials, headers: init.headers });
107
+ if (method === 'GET') {
108
+ return catalogOk ? answer(catalog) : answer({}, { ok: false, status: 500 });
109
+ }
110
+ if (patchAnswer) return patchAnswer;
111
+ // the platform's own answer: what the row now resolves to (effectiveModules)
112
+ const sent = JSON.parse(init.body).modules;
113
+ return answer({ ok: true, modules: { core: catalog.core, selected: sent, modules: catalog.core.concat(sent), source: 'chosen', adjustments: [] } });
114
+ };
115
+
116
+ const sandbox = {
117
+ $, fetch, console, Promise, JSON, String, Object, Array, Error, RegExp,
118
+ encodeURIComponent, setTimeout, clearTimeout,
119
+ API: '/api/bongos',
120
+ manageId: '18',
121
+ manageInst: { id: 18, status: 'active', modules: { core: catalog.core, selected: selected.slice(), modules: catalog.core.concat(selected), source, adjustments: [] } },
122
+ esc: (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
123
+ jsonOrEmpty: (res) => Promise.resolve(res._json),
124
+ apiError: (data, status) => ({ code: (data && data.error && data.error.code) || 'error', message: (data && data.error && data.error.message) || `HTTP ${status}` }),
125
+ signInAgain: () => '<a href="/signin">Sign in again</a>',
126
+ };
127
+ sandbox.globalThis = sandbox;
128
+ vm.createContext(sandbox);
129
+ vm.runInContext(REGION, sandbox, { filename: 'projects.html#project-extras' });
130
+
131
+ const settle = async () => { for (let i = 0; i < 40; i++) await Promise.resolve(); };
132
+ return {
133
+ requests, sandbox, settle,
134
+ rows: () => parseRows(slotHtml()),
135
+ slot: () => slotHtml(),
136
+ msg: () => $('mModMsg'),
137
+ async render() { sandbox.renderMods(sandbox.manageInst); await settle(); },
138
+ async click(key) {
139
+ const handlers = listeners.get('mMods') || [];
140
+ assert.equal(handlers.length, 1, 'the section wires exactly one delegated click handler');
141
+ const row = parseRows(slotHtml()).find((r) => r.key === key);
142
+ assert.ok(row, `the band carries a row for ${key}`);
143
+ handlers[0]({
144
+ target: {
145
+ closest: (sel) => (sel === '.modOpt[data-mod]'
146
+ ? { disabled: row.disabled, getAttribute: (a) => (a === 'data-mod' ? key : null) }
147
+ : null),
148
+ },
149
+ });
150
+ await settle();
151
+ },
152
+ };
153
+ }
154
+
155
+ // ── the tests ────────────────────────────────────────────────────────────────
156
+
157
+ test('the band renders the optional catalog the server offered — a row and a sentence each', async () => {
158
+ // Arrange
159
+ const ui = boot({ selected: ['dev-box'] });
160
+
161
+ // Act
162
+ await ui.render();
163
+
164
+ // Assert
165
+ assert.deepEqual(ui.rows().map((r) => r.key), ['dev-box', 'discord', 'agents']);
166
+ assert.deepEqual(ui.rows().map((r) => r.label), ['Dev box', 'Discord', 'Agents']);
167
+ for (const r of ui.rows()) assert.ok(r.summary.length > 20, `${r.label} says what it is for`);
168
+ });
169
+
170
+ test('what the project runs today is what is switched on — resolved by the server, not re-derived here', async () => {
171
+ // A project that SKIPPED the picker reads as its own starter bundle, and the
172
+ // band must show that set rather than an empty one: the whole point of
173
+ // effectiveModules resolving `source: 'bundle'` on the way out.
174
+ const ui = boot({ selected: ['dev-box', 'discord'], source: 'bundle' });
175
+
176
+ await ui.render();
177
+
178
+ assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['dev-box', 'discord']);
179
+ assert.deepEqual(ui.rows().filter((r) => !r.on).map((r) => r.key), ['agents']);
180
+ });
181
+
182
+ test('the always-on core is COUNTED and stated, and is not a row anyone can switch off', async () => {
183
+ const ui = boot({ selected: [] });
184
+
185
+ await ui.render();
186
+
187
+ assert.match(ui.slot(), /the same 3 core parts/, 'the clause names how many, from the server\'s own list');
188
+ assert.deepEqual(ui.rows().map((r) => r.key), ['dev-box', 'discord', 'agents'], 'and no core key is on the band');
189
+ for (const key of CATALOG.core) {
190
+ assert.equal(ui.rows().some((r) => r.key === key), false, `${key} is not a choice`);
191
+ }
192
+ });
193
+
194
+ test('a toggle issues the write — the WHOLE list, in catalog order, on the own-scoped detail channel', async () => {
195
+ // Arrange
196
+ const ui = boot({ selected: ['discord'] });
197
+ await ui.render();
198
+
199
+ // Act — switch the dev box ON
200
+ await ui.click('dev-box');
201
+
202
+ // Assert
203
+ const patch = ui.requests.find((r) => r.method === 'PATCH');
204
+ assert.ok(patch, 'a PATCH was sent');
205
+ assert.equal(patch.url, '/api/bongos/provisioning/instances/18/detail');
206
+ assert.deepEqual(JSON.parse(patch.body), { modules: ['dev-box', 'discord'] },
207
+ 'catalog order, exactly as the column stores it — never append order');
208
+ assert.equal(patch.credentials, 'same-origin', 'the cookie session is the auth — this page carries no token');
209
+ });
210
+
211
+ test('a toggle can REMOVE one too — the same list, one key shorter', async () => {
212
+ const ui = boot({ selected: ['dev-box', 'agents'] });
213
+ await ui.render();
214
+
215
+ await ui.click('agents');
216
+
217
+ const patch = ui.requests.find((r) => r.method === 'PATCH');
218
+ assert.deepEqual(JSON.parse(patch.body), { modules: ['dev-box'] });
219
+ });
220
+
221
+ test('switching the last one off sends an empty list — a real answer, not a skipped write', async () => {
222
+ const ui = boot({ selected: ['agents'] });
223
+ await ui.render();
224
+
225
+ await ui.click('agents');
226
+
227
+ const patch = ui.requests.find((r) => r.method === 'PATCH');
228
+ assert.ok(patch, 'the write still happens');
229
+ assert.deepEqual(JSON.parse(patch.body), { modules: [] });
230
+ });
231
+
232
+ test('the band repaints from the PLATFORM’S answer, and says the save changed nothing running', async () => {
233
+ const ui = boot({ selected: [] });
234
+ await ui.render();
235
+
236
+ await ui.click('discord');
237
+
238
+ assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['discord']);
239
+ assert.equal(ui.sandbox.manageInst.modules.source, 'chosen', 'the row is answered now, not still reading its bundle');
240
+ assert.equal(ui.msg().className, 'projMsg ok');
241
+ assert.match(ui.msg().textContent, /nothing to restart/i, 'this channel pushes nothing at the instance');
242
+ });
243
+
244
+ test('a refusal is SAID in the server’s own words, and the band goes back to what the row still holds', async () => {
245
+ const ui = boot({
246
+ selected: ['dev-box'],
247
+ patchAnswer: Promise.resolve({ ok: false, status: 400, _json: { error: { code: 'bad_modules', message: 'Unknown module "discrod" — the choices are: dev-box, discord, agents.' } } }),
248
+ });
249
+ await ui.render();
250
+
251
+ await ui.click('discord');
252
+
253
+ assert.equal(ui.msg().className, 'projMsg err');
254
+ assert.match(ui.msg().textContent, /Unknown module "discrod"/);
255
+ assert.deepEqual(ui.rows().filter((r) => r.on).map((r) => r.key), ['dev-box'],
256
+ 'a refused write leaves the band showing the project’s real set');
257
+ });
258
+
259
+ test('a signed-out save offers a way back in rather than a dead end', async () => {
260
+ const ui = boot({
261
+ selected: [],
262
+ patchAnswer: Promise.resolve({ ok: false, status: 401, _json: {} }),
263
+ });
264
+ await ui.render();
265
+
266
+ await ui.click('agents');
267
+
268
+ assert.match(ui.msg().innerHTML, /Sign in again/);
269
+ });
270
+
271
+ test('a catalog that ANSWERED badly says so; nothing about the project is claimed to have changed', async () => {
272
+ const ui = boot({ selected: ['dev-box'], catalogOk: false });
273
+
274
+ await ui.render();
275
+ await ui.settle();
276
+ await ui.render();
277
+
278
+ assert.match(ui.slot(), /couldn’t load the list of extras/i);
279
+ assert.match(ui.slot(), /Nothing about your project has changed/);
280
+ assert.equal(ui.rows().length, 0, 'and no band is painted from a catalog we do not have');
281
+ });
282
+
283
+ test('the catalog is read ONCE however often the section repaints', async () => {
284
+ const ui = boot({ selected: [] });
285
+
286
+ await ui.render();
287
+ await ui.render();
288
+ await ui.click('agents');
289
+
290
+ const gets = ui.requests.filter((r) => r.method === 'GET');
291
+ assert.equal(gets.length, 1, 'memoised like the wizard picker’s own read');
292
+ assert.equal(gets[0].url, '/api/bongos/provisioning/starter-bundles');
293
+ });
294
+
295
+ // ── the done panel's one pointer (criterion B) ───────────────────────────────
296
+ // A source pin, the tests/projects_hub.mjs idiom: the line is one sentence in a
297
+ // panel this file does not execute. What matters is that it exists, that it
298
+ // names THIS project rather than the list, and that it stays one line.
299
+
300
+ test('the wizard’s done panel points at the project’s settings for its extras', () => {
301
+ assert.match(HUB, /<p class="samePath" id="doneModules"><\/p>/, 'the panel carries the slot');
302
+ assert.match(SCRIPT, /\$\('doneModules'\)\.innerHTML = 'Add or remove modules later in ' \+ projectPageLink\('the project’s settings'\) \+ '\.';/,
303
+ 'and renderDone fills it with one sentence pointing at the manage page');
304
+ assert.equal((SCRIPT.match(/doneModules/g) || []).length, 1, 'one line, written in one place');
305
+ assert.match(SCRIPT, /function projectPageLink\(label\) \{\s*return '<a href="' \+ \(instanceId \? '\?view=manage&id=' \+ encodeURIComponent\(instanceId\)/,
306
+ 'and the link names the instance just created, not the list');
307
+ });
308
+
309
+ test('a manage link inside the wizard carries its project id through the in-page router', () => {
310
+ // setView reads `manageId`, never the href — so a done-panel link that only
311
+ // matched `view=(\w+)` would land every owner on a manage page with no
312
+ // project, which is what this pointer would have done.
313
+ const handler = SCRIPT.slice(SCRIPT.indexOf("var a = ev.target.closest && ev.target.closest('a[href^=\"?view=\"]');"));
314
+ assert.match(handler.slice(0, 900), /var mid = \(href\.match\(\/\[\?&\]id=\(\[\^&\]\*\)\/\) \|\| \[\]\)\[1\];/,
315
+ 'the id is lifted out of the href');
316
+ assert.match(handler.slice(0, 900), /if \(to === 'manage' && mid\) return goToManage\(decodeURIComponent\(mid\)\);/,
317
+ 'and handed to goToManage, which is what sets manageId');
318
+ });
@@ -2167,7 +2167,12 @@ test('the picker reads ONE public declaration, and it is the one the bundles com
2167
2167
  // A second source for "what may be chosen" is exactly what would let a
2168
2168
  // preselect name a module the panel has no row for (ADR 0237 §5).
2169
2169
  assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
2170
- assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 1, 'exactly once');
2170
+ // Two READERS since task 1002342 — the wizard's panel and the manage page's
2171
+ // extras band — but still ONE declaration: the count is what guards against a
2172
+ // second SOURCE, so it moves only when a reader is added, and both of these
2173
+ // name the same route.
2174
+ assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 2,
2175
+ 'the create picker and the manage page, and nothing else');
2171
2176
  // and it is a public read — no other module-catalog call joins it
2172
2177
  assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
2173
2178
  });
@@ -496,8 +496,8 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
496
496
  'and it is named by id whenever the flow has one');
497
497
  // every done-panel pointer that names a CONTROL goes the same way; the ones
498
498
  // that only say "watch it there" still name the list, which does show it
499
- assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length, 6,
500
- 'the helper and its five callers: the locked step, the domain_required snag, the domainless standup, the snagged retry, the torn-down revive');
499
+ assert.equal((SCRIPT.match(/projectPageLink\(/g) || []).length, 7,
500
+ 'the helper and its six callers: the locked step, the domain_required snag, the domainless standup, the snagged retry, the torn-down revive, and the extras pointer (task 1002342)');
501
501
  for (const dead of ['Retry it \\(and see the technical details\\) from <a href="\\?view=mine">',
502
502
  'Bring it back any time from <a href="\\?view=mine">']) {
503
503
  assert.doesNotMatch(SCRIPT, new RegExp(dead), 'a control the list no longer has must not be pointed at there');
@@ -60,6 +60,7 @@ PGDATABASE=spike
60
60
  ${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY=public
61
61
  ${NEUTRAL_PREFIX}_JOINABILITY=apply
62
62
  ${NEUTRAL_PREFIX}_VISIBILITY=public
63
+ ${NEUTRAL_PREFIX}_JOIN_GRANT=full
63
64
  `;
64
65
 
65
66
  t('replaces an existing KEY= line where it stands; every other line survives byte-for-byte', () => {
@@ -112,7 +113,7 @@ t('is PURE: same input, same output; empty body yields just the lines', () => {
112
113
  t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at standup, under the pack prefix', () => {
113
114
  const inst = { slug: 'spike', port: 3010, db_name: 'spike', hosting_shape: 'standalone', target_ref: 'jaxri/spike', settings: { platform_visibility: 'gated' } };
114
115
  const vars = P.settingsEnvVarsFor(inst);
115
- assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public' });
116
+ assert.deepEqual(vars, { [`${NEUTRAL_PREFIX}_PLATFORM_VISIBILITY`]: 'gated', [`${NEUTRAL_PREFIX}_JOINABILITY`]: 'apply', [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full' });
116
117
  const body = P.webEnvBody(inst, {});
117
118
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
118
119
  });
@@ -123,10 +124,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
123
124
  const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
124
125
 
125
126
  t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
126
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' }), GATED), true);
127
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), true);
127
128
  });
128
129
  t('false when the instance still reports a different value', () => {
129
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public' }), GATED), false);
130
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' }), GATED), false);
130
131
  assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
131
132
  });
132
133
  t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
@@ -144,7 +145,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
144
145
  function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
145
146
  manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
146
147
  const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
147
- const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public' })
148
+ const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full' })
148
149
  : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
149
150
  : manifest === 'none' ? manifestWith(undefined) : 'nope';
150
151
  const exec = (cmd, opts = {}) => {
@@ -213,7 +214,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
213
214
  assert.equal(r.ok, true);
214
215
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
215
216
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
216
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
217
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
217
218
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
218
219
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
219
220
  });
@@ -30,7 +30,8 @@ import http from 'node:http';
30
30
  // ESM named imports off the CJS module on purpose — knip cannot trace a
31
31
  // createRequire() import, so these would read as dead exports (the repo's knip
32
32
  // learning; tests/provisioning_starter_bundles.mjs does the same).
33
- import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules } from '../modules/provisioning/starter-bundles.js';
33
+ import { ALWAYS_ON_CORE, bundleForProjectType, effectiveModules, normalizeModuleSelection }
34
+ from '../modules/provisioning/starter-bundles.js';
34
35
 
35
36
  const require = createRequire(import.meta.url);
36
37
  const provisioning = require('../modules/provisioning/provisioning.js');
@@ -178,6 +179,10 @@ provisioning.updateInstanceDetail = async (_db, _id, patch) => {
178
179
  if (patch.detail !== undefined) {
179
180
  lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
180
181
  }
182
+ // `modules` REPLACES and is normalized on the way in — the real statement's
183
+ // `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
184
+ // A stub that merged would hide the removal half of "add modules later".
185
+ if (patch.modules !== undefined) lastUpdatedRow.modules = normalizeModuleSelection(patch.modules);
181
186
  return lastUpdatedRow;
182
187
  };
183
188
 
@@ -286,8 +291,9 @@ await ta('POST /instances: modules must be an array of strings', async () => {
286
291
  });
287
292
 
288
293
  await ta('POST /instances: a re-request never changes an existing project\'s modules — and SAYS so', async () => {
289
- // Same honesty as type_ignored (task 1002772). There is no mutation route yet
290
- // (R23, task 1002342 is "add modules later"), so the receipt does not name one.
294
+ // Same honesty as detail_ignored (task 1002334): since BV1.R23 (task 1002342)
295
+ // there IS a way back, so the receipt names it rather than leaving the owner
296
+ // to hunt for one.
291
297
  existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
292
298
  const res = await post('/provisioning/instances', {
293
299
  slug: 'mercury', hosting_shape: 'standalone', modules: ['discord'],
@@ -295,6 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
295
301
  assert.equal(res.status, 200, res.raw);
296
302
  assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
297
303
  assert.match(res.body.modules_ignored.hint, /never changes/);
304
+ assert.match(res.body.modules_ignored.hint, /\/detail/, 'and names the route that does change it');
298
305
  assert.deepEqual(res.body.instance.modules.selected, ['agents'], 'and the stored answer is what reads back');
299
306
  existingRow = null;
300
307
  });
@@ -341,6 +348,160 @@ await ta('PATCH /detail: a description-only patch answers with modules too — t
341
348
  detailRow = null; lastUpdatedRow = null;
342
349
  });
343
350
 
351
+ // ── Adding (and removing) a module AFTER creation ──────────────────────────
352
+ // BV1.R23, task 1002342, ADR 0244. The picker is optional at creation, and an
353
+ // optional step is only a default rather than a dead end if there is a way back
354
+ // — the same argument that made PATCH …/detail exist for the About step beside
355
+ // it. So the way back is that route's `modules` key, not a new endpoint: the
356
+ // same platform row, the same ownership gate, the same receipt.
357
+
358
+ await ta('PATCH /detail: an owner who skipped the picker can add one module later', async () => {
359
+ // Arrange — a game project that skipped: the column is NULL and the read
360
+ // resolves the type's bundle.
361
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
362
+ lastUpdatedRow = null;
363
+ const before = effectiveModules(detailRow);
364
+ assert.equal(before.source, 'bundle', 'the fixture really is an unanswered project');
365
+
366
+ // Act — the owner adds `agents` on top of what the bundle gave them
367
+ const res = await patch('/provisioning/instances/41/detail', {
368
+ modules: before.selected.concat(['agents']),
369
+ });
370
+
371
+ // Assert — stored, and the read-back is the answer rather than the bundle
372
+ assert.equal(res.status, 200, res.raw);
373
+ assert.ok(lastUpdatedRow, 'the route ran the update');
374
+ assert.deepEqual(lastUpdatedRow.modules, normalizeModuleSelection(before.selected.concat(['agents'])),
375
+ 'in catalog order, exactly as the creation write stores it');
376
+ assert.ok(res.body.modules.selected.includes('agents'), 'the added module is there');
377
+ assert.equal(res.body.modules.source, 'chosen', 'an answered project is no longer reading the bundle');
378
+ assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow), 'the reply is the POST-patch resolution');
379
+ detailRow = null; lastUpdatedRow = null;
380
+ });
381
+
382
+ await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
383
+ // The half a merging write would make impossible, which is why
384
+ // updateInstanceDetail replaces `modules` and merges `detail`.
385
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['dev-box', 'discord'], detail: {} };
386
+ lastUpdatedRow = null;
387
+
388
+ const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
389
+
390
+ assert.equal(res.status, 200, res.raw);
391
+ assert.deepEqual(lastUpdatedRow.modules, ['discord']);
392
+ assert.deepEqual(res.body.modules.selected, ['discord'], 'dev-box is gone, not still merged in');
393
+ assert.equal(res.body.modules.source, 'chosen');
394
+ detailRow = null; lastUpdatedRow = null;
395
+ });
396
+
397
+ await ta('PATCH /detail: switching them all off later is an answer, and it stays off', async () => {
398
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
399
+ lastUpdatedRow = null;
400
+
401
+ const res = await patch('/provisioning/instances/41/detail', { modules: [] });
402
+
403
+ assert.equal(res.status, 200, res.raw);
404
+ assert.deepEqual(lastUpdatedRow.modules, [], 'an empty answer is stored as one, never as NULL');
405
+ assert.deepEqual(res.body.modules.selected, []);
406
+ assert.equal(res.body.modules.source, 'chosen', 'a project that switched the bundle off does not get it back');
407
+ detailRow = null; lastUpdatedRow = null;
408
+ });
409
+
410
+ await ta('PATCH /detail: the always-on core cannot be removed — it was never on the list', async () => {
411
+ // Two guards, both pinned: the route REFUSES a core key by name, and the read
412
+ // re-adds the whole core regardless of what was stored.
413
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
414
+ lastUpdatedRow = null;
415
+
416
+ const refused = await patch('/provisioning/instances/41/detail', { modules: ['memory'] });
417
+ assert.equal(refused.status, 400, refused.raw);
418
+ assert.equal(refused.body.error.code, 'bad_modules');
419
+ assert.match(refused.body.error.message, /always on/i);
420
+ assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
421
+
422
+ const emptied = await patch('/provisioning/instances/41/detail', { modules: [] });
423
+ assert.deepEqual(emptied.body.modules.core, [...ALWAYS_ON_CORE], 'the core rides every read');
424
+ assert.deepEqual(emptied.body.modules.modules, [...ALWAYS_ON_CORE], 'and every project still has it');
425
+ detailRow = null; lastUpdatedRow = null;
426
+ });
427
+
428
+ await ta('PATCH /detail: an unknown module is refused and NAMED, exactly as at creation', async () => {
429
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
430
+ lastUpdatedRow = null;
431
+
432
+ const res = await patch('/provisioning/instances/41/detail', { modules: ['discrod'] });
433
+
434
+ assert.equal(res.status, 400, res.raw);
435
+ assert.equal(res.body.error.code, 'bad_modules');
436
+ assert.match(res.body.error.message, /discrod/, 'the message names what was wrong');
437
+ assert.match(res.body.error.message, /agents/, 'and what the choices are');
438
+ assert.equal(lastUpdatedRow, null);
439
+ detailRow = null; lastUpdatedRow = null;
440
+ });
441
+
442
+ await ta('PATCH /detail: modules must be an array of strings here too', async () => {
443
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
444
+ const notArray = await patch('/provisioning/instances/41/detail', { modules: 'discord' });
445
+ assert.equal(notArray.status, 400, notArray.raw);
446
+ assert.equal(notArray.body.error.code, 'validation_failed');
447
+
448
+ const notStrings = await patch('/provisioning/instances/41/detail', { modules: [7] });
449
+ assert.equal(notStrings.status, 400, notStrings.raw);
450
+ assert.equal(notStrings.body.error.code, 'validation_failed');
451
+ detailRow = null; lastUpdatedRow = null;
452
+ });
453
+
454
+ await ta('PATCH /detail: a modules-only body is a real change, not an empty one', async () => {
455
+ // The "nothing to change" guard predates this key; a body carrying only
456
+ // modules must not be turned away by it.
457
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
458
+ lastUpdatedRow = null;
459
+
460
+ const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
461
+
462
+ assert.equal(res.status, 200, res.raw);
463
+ assert.ok(lastUpdatedRow, 'the update ran off modules alone');
464
+ detailRow = null; lastUpdatedRow = null;
465
+ });
466
+
467
+ await ta('PATCH /detail: null modules cannot UN-ASK the question — the skip state is not writable', async () => {
468
+ // ADR 0240 §2's three-valued contract: NULL means "nobody answered". A later
469
+ // edit that could write NULL back would make a project that HAD answered
470
+ // indistinguishable from one that never was, permanently.
471
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['discord'], detail: {} };
472
+ lastUpdatedRow = null;
473
+
474
+ const res = await patch('/provisioning/instances/41/detail', { modules: null, description: 'Still here.' });
475
+
476
+ assert.equal(res.status, 200, res.raw);
477
+ assert.deepEqual(lastUpdatedRow.modules, ['discord'], 'the stored answer is untouched');
478
+ assert.equal(res.body.modules.source, 'chosen');
479
+ detailRow = null; lastUpdatedRow = null;
480
+ });
481
+
482
+ await ta('PATCH /detail: a body with only a null modules key is still nothing to change', async () => {
483
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
484
+ const res = await patch('/provisioning/instances/41/detail', { modules: null });
485
+ assert.equal(res.status, 400, res.raw);
486
+ assert.equal(res.body.error.code, 'bad_detail');
487
+ assert.match(res.body.error.message, /modules/, 'and the message names the third thing it accepts');
488
+ detailRow = null; lastUpdatedRow = null;
489
+ });
490
+
491
+ await ta('PATCH /detail: another builder’s project is a 404, not a module change', async () => {
492
+ // The ownership wall is instanceAccessDenied's, shared with every sibling
493
+ // route — pinned here because this key is new on it.
494
+ detailRow = { id: 41, owner_builder_id: 99, slug: 'not-mine', status: 'active', type: 'game', modules: null, detail: {} };
495
+ lastUpdatedRow = null;
496
+
497
+ const res = await patch('/provisioning/instances/41/detail', { modules: ['agents'] });
498
+
499
+ assert.equal(res.status, 404, res.raw);
500
+ assert.equal(res.body.error.code, 'instance_not_found');
501
+ assert.equal(lastUpdatedRow, null, 'nothing was written');
502
+ detailRow = null; lastUpdatedRow = null;
503
+ });
504
+
344
505
  server.close();
345
506
  console.log(`\n${passed} passed, ${failed} failed`);
346
507
  process.exit(failed === 0 ? 0 : 1);