@bongos/core 1.19.532 → 1.19.534

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 (54) hide show
  1. package/.bongos-core.json +113 -48
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0244-a-catalog-search-is-a-view-over-the-rows-never-an-edit-to-the-selection.md +116 -0
  5. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  6. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  7. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  8. package/docs/adr/README.md +5 -0
  9. package/docs/api/openapi.json +8 -1
  10. package/docs/api-reference.md +1 -1
  11. package/docs/copy-inventory.md +129 -124
  12. package/docs/copy-registry.json +205 -159
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +16 -0
  15. package/modules/onboarding/module.json +1 -1
  16. package/modules/onboarding/routes/access-requests.js +21 -6
  17. package/modules/platform-identity/module.json +1 -1
  18. package/modules/platform-identity/project-catalog.js +202 -0
  19. package/modules/platform-identity/routes/my-projects.js +7 -2
  20. package/modules/platform-identity/routes/projects.js +6 -0
  21. package/modules/platform-identity/routes/sso.js +10 -0
  22. package/modules/provisioning/catalog-bridge.js +105 -0
  23. package/modules/provisioning/provisioning.js +28 -2
  24. package/modules/provisioning/routes/provisioning.js +45 -11
  25. package/modules/provisioning/tests/provisioning.mjs +11 -8
  26. package/modules/public-landing/public/assets/cosmos.css +15 -0
  27. package/modules/public-landing/public/projects.html +221 -2
  28. package/modules/public-landing/public/projects.states.json +2 -1
  29. package/modules/ui-design/kit/serve.js +1 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/gds/fitness.js +9 -0
  33. package/scripts/gds/provision-net.js +7 -0
  34. package/src/bongos/project-door.js +138 -0
  35. package/src/branding.js +8 -0
  36. package/src/module-api.js +12 -1
  37. package/tests/access_requests_invite.mjs +2 -2
  38. package/tests/application_lifecycle.mjs +1 -0
  39. package/tests/catalog_only_client.mjs +242 -0
  40. package/tests/currency_label.mjs +8 -5
  41. package/tests/module_api.mjs +1 -0
  42. package/tests/project_catalog_bridge.mjs +355 -0
  43. package/tests/project_catalog_routes.mjs +213 -0
  44. package/tests/project_join_door.mjs +166 -0
  45. package/tests/project_join_door_route.mjs +206 -0
  46. package/tests/project_modules_ui.mjs +318 -0
  47. package/tests/projects_hub.mjs +28 -1
  48. package/tests/projects_hub_module_picker.mjs +207 -0
  49. package/tests/projects_hub_pre_uat.mjs +2 -2
  50. package/tests/provision_settings_apply.mjs +6 -5
  51. package/tests/provisioning_module_selection.mjs +164 -3
  52. package/tests/provisioning_settings.mjs +10 -7
  53. package/tests/provisioning_settings_apply.mjs +3 -3
  54. package/tests/provisioning_settings_env.mjs +7 -4
@@ -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);
@@ -164,7 +164,7 @@ 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', visibility: 'public' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
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, {
@@ -172,6 +172,9 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
172
172
  // the join door (task 1002324, ADR 0182 D1) — the third policy axis, offered
173
173
  // to the form the same way as its two neighbours
174
174
  visibility: ['public', 'private', 'stealth'],
175
+ // what a PUBLIC join grants (task 1002331, ADR 0247) — the fourth policy
176
+ // axis, offered to the form the same way
177
+ join_grant: ['view', 'apply', 'full'],
175
178
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
176
179
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
177
180
  // closed list, no default, no push — so it rides the same vocab
@@ -184,14 +187,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
184
187
  const r = await get('/provisioning/instances/18/settings');
185
188
  assert.equal(r.status, 200, r.raw);
186
189
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
187
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the planet is not a setting: the policy read is untouched');
190
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the planet is not a setting: the policy read is untouched');
188
191
  });
189
192
 
190
193
  await ta('stored keys overlay the defaults', async () => {
191
194
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
192
195
  const r = await get('/provisioning/instances/18/settings');
193
196
  assert.equal(r.status, 200, r.raw);
194
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' });
197
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
195
198
  });
196
199
 
197
200
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -219,7 +222,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
219
222
  reset();
220
223
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
221
224
  assert.equal(r.status, 200, r.raw);
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)');
225
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
223
226
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
224
227
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
225
228
  assert.equal(events.length, 1);
@@ -232,7 +235,7 @@ await ta('owner changes both keys in one call', async () => {
232
235
  reset();
233
236
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
234
237
  assert.equal(r.status, 200, r.raw);
235
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public' });
238
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full' });
236
239
  });
237
240
 
238
241
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -271,7 +274,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
271
274
  reset();
272
275
  const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
273
276
  assert.equal(r.status, 200, r.raw);
274
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' });
277
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' });
275
278
  assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
276
279
  assert.equal(events[0].actor, 'api:self');
277
280
  assert.match(events[0].detail, /visibility=stealth/);
@@ -331,7 +334,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
331
334
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
332
335
  assert.equal(r.status, 200, r.raw);
333
336
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
334
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'the policy half is untouched');
337
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'the policy half is untouched');
335
338
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
336
339
  assert.equal(r.body.push.queued, false);
337
340
  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', visibility: 'public' }, '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', join_grant: 'full' }, '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', visibility: 'public' });
264
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full' });
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', visibility: 'public' });
277
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full' });
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');
@@ -57,7 +57,10 @@ t('a row with NO stored settings emits today\'s behavior EXPLICITLY, never nothi
57
57
  assert.equal(v.CLOUDBONGOS_JOINABILITY, 'apply');
58
58
  // the join door rides the same transport (task 1002324, ADR 0182 D1)
59
59
  assert.equal(v.CLOUDBONGOS_VISIBILITY, 'public');
60
- assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length, 3, 'every policy key always present');
60
+ // what a public join grants rides it too (task 1002331, ADR 0182 D6)
61
+ assert.equal(v.CLOUDBONGOS_JOIN_GRANT, 'full');
62
+ assert.equal(linesOf(provisioning.settingsEnvLines({}, 'X')).length,
63
+ Object.keys(provisioning.SETTINGS_VOCAB).length, 'every policy key always present');
61
64
  });
62
65
 
63
66
  t('an off-vocabulary stored value falls back to the default, never reaches the instance', () => {
@@ -96,11 +99,11 @@ t('it names the live push by the intent the runner actually dispatches (task 100
96
99
  });
97
100
 
98
101
  t('settingsEnvVars is the single source both legs derive from — same keys, same values as the lines', () => {
99
- const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth' } };
102
+ const row = { settings: { platform_visibility: 'gated', joinability: 'open', visibility: 'stealth', join_grant: 'view' } };
100
103
  const vars = provisioning.settingsEnvVars(row, 'CLOUDBONGOS');
101
- assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth' });
104
+ assert.deepEqual(vars, { CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth', CLOUDBONGOS_JOIN_GRANT: 'view' });
102
105
  assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
103
- assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public' }, 'defaults, explicitly');
106
+ assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), { X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full' }, 'defaults, explicitly');
104
107
  });
105
108
 
106
109
  console.log('\nwebEnvBody — the settings ride the real per-instance env file:');