@bongos/core 1.19.511 → 1.19.513

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 (50) hide show
  1. package/.bongos-core.json +85 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/config/branding.neutral.json +3 -2
  8. package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
  9. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +40 -3
  12. package/docs/api-reference.md +3 -2
  13. package/docs/copy-inventory.md +158 -151
  14. package/docs/copy-registry.json +313 -229
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/hall-kit.js +309 -0
  18. package/modules/hall-ui/public/hall-render.js +10 -38
  19. package/modules/hall-ui/public/profile-charts.js +58 -65
  20. package/modules/hall-ui/public/profile.css +9 -18
  21. package/modules/hall-ui/public/style.css +101 -15
  22. package/modules/hall-ui/public/watch.css +6 -14
  23. package/modules/hall-ui/public/watch.html +1 -1
  24. package/modules/hall-ui/public/watch.js +139 -54
  25. package/modules/provisioning/provisioning.js +27 -1
  26. package/modules/provisioning/recommendations.js +260 -0
  27. package/modules/provisioning/routes/provisioning.js +29 -0
  28. package/modules/provisioning/tests/provisioning.mjs +17 -14
  29. package/modules/public-landing/public/projects.html +125 -0
  30. package/modules/ui-design/kit/serve.js +4 -3
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/src/branding.js +9 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/currency_label.mjs +5 -4
  36. package/tests/hall_audit.mjs +84 -0
  37. package/tests/hall_kit_charts.mjs +328 -0
  38. package/tests/hall_profile_world.mjs +27 -6
  39. package/tests/helpers.mjs +161 -0
  40. package/tests/joinability_invite_only.mjs +10 -2
  41. package/tests/project_door_ui.mjs +267 -0
  42. package/tests/projects_hub.mjs +73 -1
  43. package/tests/projects_hub_pre_uat.mjs +4 -1
  44. package/tests/provision_settings_apply.mjs +8 -7
  45. package/tests/provisioning_recommendations.mjs +448 -0
  46. package/tests/provisioning_settings.mjs +65 -6
  47. package/tests/provisioning_settings_apply.mjs +3 -3
  48. package/tests/provisioning_settings_env.mjs +10 -8
  49. package/tests/task_detail_ui.mjs +1 -153
  50. package/tests/watch_applications_queue.mjs +381 -0
@@ -0,0 +1,448 @@
1
+ // tests/provisioning_recommendations.mjs — the rule-based recommendation engine
2
+ // (BV1.R17, task 1002336; goal 1000046 criteria `proj-module-select`,
3
+ // `proj-governance-reco`, `proj-invite-builders`). Decision record: ADR 0239.
4
+ //
5
+ // Four things are pinned here:
6
+ //
7
+ // 1. Each rule fires on its condition and NOT otherwise — the whole claim of a
8
+ // rule engine, and the thing a table quietly breaks when a fourth row lands.
9
+ // 2. Skipping the interview yields the stated defaults, and an off-vocabulary
10
+ // answer is read as no answer. Criterion `proj-detail-interview` splits on
11
+ // exactly this ("completing it produces tailored recommendations; skipping
12
+ // produces sensible defaults"), and R18 (task 1002337) proves it live.
13
+ // 3. Table integrity — every module a rule names is a real OPT-IN module on
14
+ // disk, every governance rule names a preset that exists, and every team
15
+ // shape in the interview's own vocabulary has a rule. The ADR 0237 idiom: a
16
+ // pure file declares, and this test is the link back to the live catalog.
17
+ // 4. The read surface — GET /provisioning/recommendations is public, needs no
18
+ // database, and hands back the same bundle shape the starter-bundles route
19
+ // serves, so a recommendation and a preselect can never name different
20
+ // modules.
21
+ //
22
+ // DB-free: the route is a pure read, so nothing here touches Postgres or the
23
+ // network beyond a loopback listener on port 0.
24
+ //
25
+ // Run: node --test --test-reporter=tap tests/provisioning_recommendations.mjs
26
+
27
+ import { test, before, after } from 'node:test';
28
+ import { strict as assert } from 'node:assert';
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import http from 'node:http';
32
+ import { fileURLToPath } from 'node:url';
33
+ import { createRequire } from 'node:module';
34
+
35
+ // ESM named imports off the CJS module on purpose: knip cannot trace a
36
+ // createRequire() import, so a test-only export would read as dead code and move
37
+ // the knip ratchet (see the repo's knip learning).
38
+ import {
39
+ TEAM_SHAPE_RULES,
40
+ GOVERNANCE_PRESETS,
41
+ GOVERNANCE_RULES,
42
+ GOVERNANCE_DEFAULT,
43
+ INVITE_RULES,
44
+ INVITE_DEFAULT,
45
+ recommendationsForDetails,
46
+ } from '../modules/provisioning/recommendations.js';
47
+ import {
48
+ ALWAYS_ON_CORE,
49
+ HUB_ONLY_MODULES,
50
+ bundleForProjectType,
51
+ } from '../modules/provisioning/starter-bundles.js';
52
+
53
+ const require = createRequire(import.meta.url);
54
+
55
+ // Stub the doorway in require.cache BEFORE the route file loads (the
56
+ // access_requests_invite pattern): the route destructures `pool` at require time,
57
+ // and a real pg pool would hold the event loop open long after the assertions
58
+ // pass. Nothing here needs a database — the route under test is a pure read, and
59
+ // this stub is also the assertion that proves it.
60
+ const apiPath = require.resolve('../src/module-api.js');
61
+ const realApi = require(apiPath);
62
+ require.cache[apiPath].exports = {
63
+ ...realApi,
64
+ pool: { async query() { throw new Error('the recommendations route must not touch the database'); } },
65
+ };
66
+
67
+ const provisioning = require('../modules/provisioning/provisioning.js');
68
+
69
+ const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
70
+
71
+ // Every module key on disk, with the manifest's `default` — the live catalog as
72
+ // the loader discovers it (src/module-loader/loader.js scans exactly this).
73
+ function manifestsOnDisk() {
74
+ const out = new Map();
75
+ for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
76
+ if (!ent.isDirectory()) continue;
77
+ const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
78
+ if (!fs.existsSync(manifestPath)) continue;
79
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
80
+ out.set(manifest.key, manifest);
81
+ }
82
+ return out;
83
+ }
84
+
85
+ // ── the rules fire on their condition, and not otherwise ────────────────────
86
+
87
+ test('an open community adds somewhere to gather to a bundle that lacks one', () => {
88
+ // Arrange — the research bundle is `agents` alone (ADR 0237 §4), so the rule
89
+ // has something to actually change.
90
+ const answers = { type: 'research', team_shape: 'community' };
91
+
92
+ // Act
93
+ const recs = recommendationsForDetails(answers);
94
+
95
+ // Assert
96
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
97
+ assert.equal(recs.bundle.adjustments.length, 1);
98
+ assert.equal(recs.bundle.adjustments[0].module, 'discord');
99
+ assert.equal(recs.bundle.adjustments[0].effect, 'add');
100
+ assert.equal(recs.bundle.adjustments[0].rule, 'a-community-needs-somewhere-to-gather');
101
+ assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
102
+ });
103
+
104
+ test('a small team adds the shared build environment', () => {
105
+ // Arrange
106
+ const answers = { type: 'research', team_shape: 'small-team' };
107
+
108
+ // Act
109
+ const recs = recommendationsForDetails(answers);
110
+
111
+ // Assert
112
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
113
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
114
+ });
115
+
116
+ test('a solo builder drops the shared build environment from the bundle', () => {
117
+ // Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
118
+ const answers = { type: 'business', team_shape: 'solo' };
119
+
120
+ // Act
121
+ const recs = recommendationsForDetails(answers);
122
+
123
+ // Assert
124
+ assert.deepEqual(recs.bundle.bundle, ['agents']);
125
+ assert.equal(recs.bundle.adjustments.length, 1);
126
+ assert.equal(recs.bundle.adjustments[0].effect, 'remove');
127
+ assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
128
+ });
129
+
130
+ test('a rule does not fire for a team shape it does not name', () => {
131
+ // Arrange — `solo` removes dev-box; `community` must leave it exactly alone.
132
+ const answers = { type: 'business', team_shape: 'community' };
133
+
134
+ // Act
135
+ const recs = recommendationsForDetails(answers);
136
+
137
+ // Assert
138
+ assert.ok(recs.bundle.bundle.includes('dev-box'), 'the solo removal did not leak onto another shape');
139
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.rule), ['a-community-needs-somewhere-to-gather']);
140
+ });
141
+
142
+ test('a rule whose module the bundle already carries records no adjustment', () => {
143
+ // The game bundle already contains `discord`, so the community rule has nothing
144
+ // to change. An adjustment list is a DELTA — claiming a change that did not
145
+ // happen is a reason the owner cannot check against the picker in front of them.
146
+ // Arrange
147
+ const answers = { type: 'game', team_shape: 'community' };
148
+
149
+ // Act
150
+ const recs = recommendationsForDetails(answers);
151
+
152
+ // Assert
153
+ assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle], 'the bundle is untouched');
154
+ assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
155
+ });
156
+
157
+ test('a removal rule whose module is absent records no adjustment', () => {
158
+ // Arrange — `non-profit` is `discord` alone, so solo\'s dev-box removal is a no-op.
159
+ const answers = { type: 'non-profit', team_shape: 'solo' };
160
+
161
+ // Act
162
+ const recs = recommendationsForDetails(answers);
163
+
164
+ // Assert
165
+ assert.deepEqual(recs.bundle.bundle, ['discord']);
166
+ assert.deepEqual(recs.bundle.adjustments, []);
167
+ });
168
+
169
+ test('each team shape maps to its own governance preset and invite seek', () => {
170
+ // Arrange
171
+ const expected = {
172
+ solo: { preset: 'owner-only', seeking: null },
173
+ 'small-team': { preset: 'trusted-team', seeking: { kind: 'top-builders', limit: 3 } },
174
+ community: { preset: 'open-contribution', seeking: { kind: 'top-builders', limit: 5 } },
175
+ };
176
+
177
+ // Act + Assert
178
+ for (const [shape, want] of Object.entries(expected)) {
179
+ const recs = recommendationsForDetails({ type: 'not-sure', team_shape: shape });
180
+ assert.equal(recs.governance.preset, want.preset, `${shape} → ${want.preset}`);
181
+ assert.deepEqual(recs.invites.seeking, want.seeking, `${shape} → its own invite seek`);
182
+ assert.ok(recs.governance.reason.length > 0, 'the preset always says why it was picked');
183
+ }
184
+ });
185
+
186
+ // ── skipping the interview, and answers that are not answers ────────────────
187
+
188
+ test('skipping the interview returns the type bundle unchanged, the narrowest governance and no invites', () => {
189
+ // Arrange — a declared type but no interview answer at all: the supported path
190
+ // (ADR 0182 D4), not a gap.
191
+ const answers = { type: 'game' };
192
+
193
+ // Act
194
+ const recs = recommendationsForDetails(answers);
195
+
196
+ // Assert
197
+ assert.equal(recs.teamShape, null);
198
+ assert.equal(recs.tailored, false, 'nothing here came from an answer the owner gave');
199
+ assert.deepEqual(recs.bundle.bundle, [...bundleForProjectType('game').bundle]);
200
+ assert.deepEqual(recs.bundle.adjustments, []);
201
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
202
+ assert.equal(recs.governance.reason, GOVERNANCE_DEFAULT.reason);
203
+ assert.deepEqual(recs.invites.suggested, []);
204
+ assert.equal(recs.invites.seeking, null);
205
+ assert.equal(recs.invites.reason, INVITE_DEFAULT.reason);
206
+ });
207
+
208
+ test('answering the interview marks the recommendation tailored', () => {
209
+ // Arrange + Act
210
+ const answered = recommendationsForDetails({ type: 'game', team_shape: 'solo' });
211
+ const skipped = recommendationsForDetails({ type: 'game' });
212
+
213
+ // Assert — the machine-readable half of criterion `proj-detail-interview`.
214
+ assert.equal(answered.tailored, true);
215
+ assert.equal(skipped.tailored, false);
216
+ });
217
+
218
+ test('an off-vocabulary team shape is read as no answer, not as a new rule', () => {
219
+ // A value that was never in the vocabulary — or has since left it — must land
220
+ // on the defaults, exactly as normalizeProjectDetail drops it on the way in.
221
+ // Inventing an answer nobody gave is the failure this file exists to prevent.
222
+ for (const shape of [null, undefined, '', 'guild', 'SOLO', 'small team', 42, {}, ['solo']]) {
223
+ // Act
224
+ const recs = recommendationsForDetails({ type: 'business', team_shape: shape });
225
+
226
+ // Assert
227
+ assert.equal(recs.teamShape, null, `${JSON.stringify(shape)} → no answer`);
228
+ assert.equal(recs.tailored, false);
229
+ assert.deepEqual(recs.bundle.adjustments, [], 'no rule fires on a value nobody chose');
230
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
231
+ }
232
+ });
233
+
234
+ test('a call with no answers at all recommends the always-on core and nothing else', () => {
235
+ // Act — the signed-out wizard's very first ask, before any question is answered.
236
+ for (const input of [undefined, null, {}, 'nonsense', []]) {
237
+ const recs = recommendationsForDetails(input);
238
+
239
+ // Assert
240
+ assert.equal(recs.type, null, 'never guesses a type');
241
+ assert.deepEqual(recs.bundle.bundle, [], 'nothing is preselected on a guess');
242
+ assert.deepEqual(recs.bundle.modules, [...ALWAYS_ON_CORE]);
243
+ assert.equal(recs.governance.preset, GOVERNANCE_DEFAULT.preset);
244
+ assert.equal(recs.invites.seeking, null);
245
+ }
246
+ });
247
+
248
+ test('an unknown project type recommends the core, even when the interview was answered', () => {
249
+ // Arrange — the stale seeded vocabulary (ADR 0237 §2) is the realistic caller.
250
+ const answers = { type: 'saas-tool', team_shape: 'community' };
251
+
252
+ // Act
253
+ const recs = recommendationsForDetails(answers);
254
+
255
+ // Assert — the type is refused, but the ANSWER the owner actually gave still counts.
256
+ assert.equal(recs.type, null);
257
+ assert.equal(recs.teamShape, 'community');
258
+ assert.deepEqual(recs.bundle.bundle, ['discord'], 'the community rule applies to an empty bundle');
259
+ assert.equal(recs.governance.preset, 'open-contribution');
260
+ });
261
+
262
+ // ── the recommendation is a preview, and the invites are a seam ─────────────
263
+
264
+ test('governance is a named preview and is never applied', () => {
265
+ // The governance module is planned and unbuilt (goal 1000030), which criterion
266
+ // `proj-governance-reco` states outright. Flip this pin when R25 (task 1002344)
267
+ // fits the presets to the real module.
268
+ for (const shape of [...provisioning.PROJECT_DETAIL_VOCAB.team_shape, null]) {
269
+ // Act
270
+ const recs = recommendationsForDetails({ type: 'game', team_shape: shape });
271
+
272
+ // Assert
273
+ assert.equal(recs.governance.applied, false, 'a recommendation, never a setting');
274
+ assert.ok(recs.governance.label, 'carries owner-facing copy');
275
+ assert.ok(recs.governance.preview, 'and one sentence of what it feels like');
276
+ }
277
+ });
278
+
279
+ test('invites are a seam: the suggested list is empty and the seek says what to look for', () => {
280
+ // Ranking builders means querying the leaderboard, which lives in the `economy`
281
+ // module this pure file may not import (the one-way rule, ADR 0083). R28 (task
282
+ // 1002347) owns the lookup; the engine only states what it should look for.
283
+ // Arrange
284
+ const answers = { type: 'business', team_shape: 'small-team' };
285
+
286
+ // Act
287
+ const recs = recommendationsForDetails(answers);
288
+
289
+ // Assert
290
+ assert.deepEqual(recs.invites.suggested, []);
291
+ assert.equal(recs.invites.seeking.kind, 'top-builders');
292
+ assert.equal(typeof recs.invites.seeking.limit, 'number');
293
+ assert.ok(recs.invites.reason.length > 0);
294
+ });
295
+
296
+ test('a caller may mutate the result without corrupting the declaration', () => {
297
+ // Arrange
298
+ const first = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
299
+
300
+ // Act — the picker sorts and splices what it is handed.
301
+ first.bundle.bundle.push('not-a-module');
302
+ first.bundle.modules.length = 0;
303
+ first.invites.seeking.limit = 999;
304
+
305
+ // Assert
306
+ const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
307
+ assert.deepEqual(second.bundle.bundle, ['dev-box', 'discord']);
308
+ assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'dev-box', 'discord']);
309
+ assert.equal(second.invites.seeking.limit, 3);
310
+ });
311
+
312
+ // ── table integrity ─────────────────────────────────────────────────────────
313
+
314
+ test('every module a rule names is a real opt-in module in the catalog', () => {
315
+ // Arrange
316
+ const manifests = manifestsOnDisk();
317
+
318
+ // Act + Assert
319
+ for (const rule of TEAM_SHAPE_RULES) {
320
+ const manifest = manifests.get(rule.module);
321
+ assert.ok(manifest, `rule ${rule.id} names '${rule.module}', which is not a module on disk`);
322
+ assert.notEqual(manifest.default, true, `rule ${rule.id} names always-on '${rule.module}' — a toggle that toggles nothing`);
323
+ assert.ok(!ALWAYS_ON_CORE.includes(rule.module), `rule ${rule.id} names a core module`);
324
+ assert.ok(!HUB_ONLY_MODULES.includes(rule.module), `rule ${rule.id} names hub-only '${rule.module}'`);
325
+ }
326
+ });
327
+
328
+ test('every team shape the interview can return has a governance rule and an invite rule', () => {
329
+ // Arrange — the interview's own vocabulary is the source of truth. A fourth
330
+ // answer added there must not be silently unhandled here.
331
+ const vocab = provisioning.PROJECT_DETAIL_VOCAB.team_shape;
332
+
333
+ // Act + Assert
334
+ for (const shape of vocab) {
335
+ assert.ok(GOVERNANCE_RULES.some((r) => r.teamShape === shape), `no governance rule for '${shape}'`);
336
+ assert.ok(INVITE_RULES.some((r) => r.teamShape === shape), `no invite rule for '${shape}'`);
337
+ }
338
+ for (const rule of [...TEAM_SHAPE_RULES, ...GOVERNANCE_RULES, ...INVITE_RULES]) {
339
+ assert.ok(vocab.includes(rule.teamShape), `a rule names '${rule.teamShape}', which the interview cannot return`);
340
+ }
341
+ });
342
+
343
+ test('every governance rule names a preset that exists, and the default does too', () => {
344
+ // Act + Assert
345
+ for (const rule of [...GOVERNANCE_RULES, GOVERNANCE_DEFAULT]) {
346
+ const preset = GOVERNANCE_PRESETS[rule.preset];
347
+ assert.ok(preset, `no preset '${rule.preset}'`);
348
+ assert.ok(preset.label && preset.preview, `preset '${rule.preset}' is missing owner-facing copy`);
349
+ }
350
+ });
351
+
352
+ test('the rule tables carry exactly one row per shape, so no rule can shadow another', () => {
353
+ // Act + Assert — GOVERNANCE_RULES and INVITE_RULES are resolved by `find`, so a
354
+ // duplicate row would be dead code that reads as live.
355
+ for (const [name, table] of [['governance', GOVERNANCE_RULES], ['invite', INVITE_RULES]]) {
356
+ const shapes = table.map((r) => r.teamShape);
357
+ assert.equal(new Set(shapes).size, shapes.length, `duplicate team shape in the ${name} table`);
358
+ }
359
+ const ruleIds = TEAM_SHAPE_RULES.map((r) => r.id);
360
+ assert.equal(new Set(ruleIds).size, ruleIds.length, 'duplicate bundle rule id');
361
+ });
362
+
363
+ // ── the read surface ────────────────────────────────────────────────────────
364
+
365
+ // Mount the real router. The route under test is a pure read, so it needs no
366
+ // session gate and no res.fail helper — which is itself the point of the
367
+ // assertions below.
368
+ const express = require('express');
369
+ const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
370
+ const app = express();
371
+ app.use('/api/bongos', buildProvisioningRouter());
372
+
373
+ let server;
374
+ let port;
375
+ before(async () => {
376
+ server = http.createServer(app);
377
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
378
+ port = server.address().port;
379
+ });
380
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
381
+
382
+ function get(pathname) {
383
+ return new Promise((resolve, reject) => {
384
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
385
+ let body = '';
386
+ res.on('data', (c) => { body += c; });
387
+ res.on('end', () => {
388
+ let parsed = null;
389
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
390
+ resolve({ status: res.statusCode, body: parsed, raw: body });
391
+ });
392
+ }).on('error', reject);
393
+ });
394
+ }
395
+
396
+ test('GET /provisioning/recommendations is public and answers with no session', async () => {
397
+ // Act
398
+ const res = await get('/api/bongos/provisioning/recommendations?type=game&team_shape=community');
399
+
400
+ // Assert
401
+ assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
402
+ const recs = res.body.recommendations;
403
+ assert.equal(recs.type, 'game');
404
+ assert.equal(recs.teamShape, 'community');
405
+ assert.equal(recs.tailored, true);
406
+ assert.ok(Array.isArray(recs.bundle.modules));
407
+ assert.ok(recs.governance.preset);
408
+ assert.ok(Array.isArray(recs.invites.suggested));
409
+ });
410
+
411
+ test('the route serves exactly what the engine returns', async () => {
412
+ // Arrange
413
+ const expected = recommendationsForDetails({ type: 'business', team_shape: 'solo' });
414
+
415
+ // Act
416
+ const res = await get('/api/bongos/provisioning/recommendations?type=business&team_shape=solo');
417
+
418
+ // Assert
419
+ assert.deepEqual(res.body.recommendations, expected, 'a script and the wizard read one answer');
420
+ });
421
+
422
+ test('the route reads the same bundle shape the starter-bundles route serves', async () => {
423
+ // ADR 0237 §5's promise: a recommendation and a preselect can never name
424
+ // different modules, because both resolve through bundleForProjectType.
425
+ // Act
426
+ const [bundles, recs] = await Promise.all([
427
+ get('/api/bongos/provisioning/starter-bundles'),
428
+ get('/api/bongos/provisioning/recommendations?type=research'),
429
+ ]);
430
+
431
+ // Assert — no interview answer, so the recommendation IS the preselect.
432
+ const preset = bundles.body.bundles.find((b) => b.type === 'research');
433
+ const recommended = recs.body.recommendations.bundle;
434
+ assert.deepEqual(recommended.core, preset.core);
435
+ assert.deepEqual(recommended.bundle, preset.bundle);
436
+ assert.deepEqual(recommended.modules, preset.modules);
437
+ assert.equal(recommended.label, preset.label);
438
+ });
439
+
440
+ test('the route answers the defaults for missing or unusable query values', async () => {
441
+ // Act + Assert — an owner who skipped the step must get a 200 with the
442
+ // defaults, never a 400: skipping is a supported answer (ADR 0182 D4).
443
+ for (const query of ['', '?type=', '?team_shape=guild', '?type=saas-tool', '?type[]=game&team_shape[]=solo']) {
444
+ const res = await get(`/api/bongos/provisioning/recommendations${query}`);
445
+ assert.equal(res.status, 200, `${query || '(no query)'} → 200, got ${res.status} ${res.raw}`);
446
+ assert.ok(res.body.recommendations, 'and a recommendation, not an error envelope');
447
+ }
448
+ });
@@ -164,11 +164,14 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
171
171
  platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
172
+ // the join door (task 1002324, ADR 0182 D1) — the third policy axis, offered
173
+ // to the form the same way as its two neighbours
174
+ visibility: ['public', 'private', 'stealth'],
172
175
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
173
176
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
174
177
  // closed list, no default, no push — so it rides the same vocab
@@ -181,14 +184,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
181
184
  const r = await get('/provisioning/instances/18/settings');
182
185
  assert.equal(r.status, 200, r.raw);
183
186
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
184
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
187
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the planet is not a setting: the policy read is untouched');
185
188
  });
186
189
 
187
190
  await ta('stored keys overlay the defaults', async () => {
188
191
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
189
192
  const r = await get('/provisioning/instances/18/settings');
190
193
  assert.equal(r.status, 200, r.raw);
191
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only' });
194
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' });
192
195
  });
193
196
 
194
197
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -216,7 +219,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
216
219
  reset();
217
220
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
218
221
  assert.equal(r.status, 200, r.raw);
219
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
222
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
220
223
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
221
224
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
222
225
  assert.equal(events.length, 1);
@@ -229,7 +232,7 @@ await ta('owner changes both keys in one call', async () => {
229
232
  reset();
230
233
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
231
234
  assert.equal(r.status, 200, r.raw);
232
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only' });
235
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public' });
233
236
  });
234
237
 
235
238
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -257,6 +260,62 @@ await ta('an off-vocabulary value → 400 validation_failed (nothing written)',
257
260
  assert.equal(updateCalls.length, 0);
258
261
  });
259
262
 
263
+ // ── the join door (BV1.R05, task 1002324) ────────────────────────────────────
264
+ // ADR 0182 D1's STORED axis — Public / Private / Stealth — reaching the owner on
265
+ // the same own-scoped channel as its two neighbours. What these pin: the server,
266
+ // not the form, decides a value is legal; a fourth state cannot be picked because
267
+ // dark matter is DERIVED (D2); and the door is a POLICY, so it rides the push.
268
+ console.log('\nPATCH /provisioning/instances/:id/settings — the join door (task 1002324):');
269
+
270
+ await ta('the owner sets the door to stealth — stored, echoed effective, audited, and pushed like the policy it is', async () => {
271
+ reset();
272
+ const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
273
+ assert.equal(r.status, 200, r.raw);
274
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' });
275
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
276
+ assert.equal(events[0].actor, 'api:self');
277
+ assert.match(events[0].detail, /visibility=stealth/);
278
+ // ADR 0182 D6 makes the door the CEILING joinability may only narrow, and D1/D5
279
+ // put that authority on the instance — so it must ride web.env + the restart,
280
+ // not the planet's draw-only channel.
281
+ assert.equal(r.body.push.queued, true, 'the door is a policy the instance obeys — it pushes');
282
+ assert.equal(r.body.push.restart, true, 'and a restart is what applies it');
283
+ });
284
+
285
+ await ta('each of the three states is accepted', async () => {
286
+ for (const value of ['public', 'private', 'stealth']) {
287
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { visibility: value === 'public' ? 'stealth' : 'public' } } });
288
+ const r = await patch('/provisioning/instances/18/settings', { visibility: value });
289
+ assert.equal(r.status, 200, `${value}: ${r.raw}`);
290
+ assert.equal(r.body.saved.visibility, value);
291
+ }
292
+ });
293
+
294
+ await ta('a fourth state is refused server-side — dark matter is DERIVED, never picked (ADR 0182 D2)', async () => {
295
+ for (const bogus of ['unlisted', 'dark-matter', 'gated', 'hidden', '']) {
296
+ reset();
297
+ const r = await patch('/provisioning/instances/18/settings', { visibility: bogus });
298
+ assert.equal(r.status, 400, `${JSON.stringify(bogus)} must be refused: ${r.raw}`);
299
+ assert.equal(r.body.error.code, 'validation_failed');
300
+ assert.equal(updateCalls.length, 0, 'nothing written on a refused value');
301
+ }
302
+ });
303
+
304
+ await ta('the OWN-SCOPE WALL holds for the door: another builder\'s PATCH → 404, nothing written', async () => {
305
+ reset({ as: { id: 8, github_login: 'stranger', rank: 'thetes' } });
306
+ const r = await patch('/provisioning/instances/18/settings', { visibility: 'private' });
307
+ assert.equal(r.status, 404, r.raw);
308
+ assert.equal(r.body.error.code, 'instance_not_found', 'no existence leak');
309
+ assert.equal(updateCalls.length, 0);
310
+ assert.equal(events.length, 0);
311
+ });
312
+
313
+ await ta('an unknown project → 404 for the door too, archon included', async () => {
314
+ reset({ inst: null, as: { id: 999, github_login: 'archon', rank: 'archon' } });
315
+ const r = await patch('/provisioning/instances/999/settings', { visibility: 'private' });
316
+ assert.equal(r.status, 404, r.raw);
317
+ });
318
+
260
319
  await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dropped)', async () => {
261
320
  reset();
262
321
  const r = await patch('/provisioning/instances/18/settings', { joinabilty: 'open' });
@@ -272,7 +331,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
272
331
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
273
332
  assert.equal(r.status, 200, r.raw);
274
333
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
275
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
334
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the policy half is untouched');
276
335
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
277
336
  assert.equal(r.body.push.queued, false);
278
337
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
@@ -212,7 +212,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
212
212
  reset();
213
213
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
214
214
  assert.equal(r.status, 200, r.raw);
215
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
215
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
216
216
  assert.equal(r.body.settings, undefined);
217
217
  assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
218
218
  assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
@@ -261,7 +261,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
261
261
  enqueueThrows = new Error('db down');
262
262
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
263
263
  assert.equal(r.status, 200, r.raw);
264
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open' });
264
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public' });
265
265
  assert.equal(updateCalls.length, 1, 'the write landed');
266
266
  receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
267
267
  assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
@@ -274,7 +274,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
274
274
  reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
275
275
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
276
276
  assert.equal(r.status, 200, r.raw);
277
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open' });
277
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public' });
278
278
  assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
279
279
  assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
280
280
  assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
@@ -55,7 +55,9 @@ t('a row with NO stored settings emits today\'s behavior EXPLICITLY, never nothi
55
55
  // an instance must never have to infer a policy from an absent variable
56
56
  assert.equal(v.CLOUDBONGOS_PLATFORM_VISIBILITY, 'public');
57
57
  assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
58
- assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length, 2, 'both keys always present');
58
+ // the join door rides the same transport (task 1002324, ADR 0182 D1)
59
+ assert.equal(v.CLOUDBONGOS_VISIBILITY, 'public');
60
+ assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length, 3, 'every policy key always present');
59
61
  });
60
62
 
61
63
  t('an off-vocabulary stored value falls back to the default, never reaches the instance', () => {
@@ -94,11 +96,11 @@ t('it names the live push by the intent the runner actually dispatches (task 100
94
96
  });
95
97
 
96
98
  t('settingsEnvVars is the single source both legs derive from — same keys, same values as the lines', () => {
97
- const row = { settings: { platform_visibility: 'gated', joinability: 'open' } };
99
+ const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth' } };
98
100
  const vars = provisioning.settingsEnvVars(row, 'CLOUDBONGOS');
99
- assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open' });
101
+ assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth' });
100
102
  assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
101
- assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply' }, 'defaults, explicitly');
103
+ assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public' }, 'defaults, explicitly');
102
104
  });
103
105
 
104
106
  console.log('\nwebEnvBody — the settings ride the real per-instance env file:');
@@ -131,12 +133,12 @@ t('clientBranding\'s project fallbacks are DEFAULT_SETTINGS, key for key — the
131
133
 
132
134
  t('the env override resolves onto project.* in a real process', () => {
133
135
  const read = (env) => JSON.parse(execFileSync(process.execPath, ['-e',
134
- "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability}))"],
136
+ "const b=require('./src/branding.js');const r=(b.branding||b)();console.log(JSON.stringify({v:r.project.platformVisibility,j:r.project.joinability,d:r.project.visibility}))"],
135
137
  { cwd: ROOT, encoding: 'utf8', env: { ...process.env, ...env } }));
136
- assert.deepEqual(read({}), { v: 'public', j: 'apply' }, 'no env → the pack default');
138
+ assert.deepEqual(read({}), { v: 'public', j: 'apply', d: 'public' }, 'no env → the pack default');
137
139
  assert.deepEqual(
138
- read({ CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'invite_only' }),
139
- { v: 'gated', j: 'invite_only' },
140
+ read({ CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'invite_only', CLOUDBONGOS_VISIBILITY: 'stealth' }),
141
+ { v: 'gated', j: 'invite_only', d: 'stealth' },
140
142
  'the web.env the runner writes is what the instance reads');
141
143
  });
142
144