@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
@@ -42,6 +42,7 @@ const { onboardPlan, stepsForShape } = require('../onboard-plan');
42
42
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
43
43
  const { recommendationsForDetails } = require('../recommendations');
44
44
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
45
+ const catalogBridge = require('../catalog-bridge');
45
46
  const { callbackPage } = require('./callback-page');
46
47
  // task 1003208: structured logging (pino via the doorway) — was console.*.
47
48
  const log = api.logger('provisioning');
@@ -479,6 +480,12 @@ module.exports = function provisioningRoutes() {
479
480
  actor: 'api:self',
480
481
  }).catch(() => {});
481
482
  }
483
+ // The project joins the hub catalog the moment it EXISTS, not when (or whether) it
484
+ // later federates as a co-tenant (task 1003576, ADR 0246). It is dark matter until a
485
+ // description exists — the publish gate alone decides that — but the row has to be
486
+ // there for the gate to have anything to decide about, and for the wizard's invite
487
+ // step to have a project to invite into while standup is still running.
488
+ await catalogBridge.publishInstanceToCatalog(pool, instance).catch(() => {});
482
489
  // A re-request cannot change an existing instance's domain — SAY so instead of
483
490
  // silently dropping it (task 1002692); the domain route is the real path.
484
491
  let domainIgnored;
@@ -520,14 +527,13 @@ module.exports = function provisioningRoutes() {
520
527
  hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
521
528
  };
522
529
  }
523
- // And the same for the picker's module set (task 1002339). Like `type` —
524
- // and unlike detail — there is no mutation route yet (R23, task 1002342 is
525
- // "add modules later"), so the receipt says the answer was kept rather
526
- // than naming a way back that does not exist.
530
+ // And the same for the picker's module set (task 1002339). Like detail —
531
+ // and unlike `type` — this one HAS a way back since BV1.R23 (task 1002342),
532
+ // so the receipt names it instead of leaving the owner to hunt for one.
527
533
  let modulesIgnored;
528
534
  if (!created && body.modules !== undefined) {
529
535
  modulesIgnored = {
530
- hint: "a re-request never changes an existing instance's module selection",
536
+ hint: `a re-request never changes an existing instance's module selection — PATCH /provisioning/instances/${instance.id}/detail with { modules } instead`,
531
537
  };
532
538
  }
533
539
  res.status(created ? 201 : 200).json({
@@ -653,9 +659,9 @@ module.exports = function provisioningRoutes() {
653
659
 
654
660
  // ── The optional project detail (task 1002334, goal 1000046) ──────────────
655
661
  // PATCH /provisioning/instances/:id/detail — answer (or re-answer) the
656
- // "tell us about your project" step after creation. Body: any subset of
657
- // { description, detail }. rank: any authenticated builder (own resource;
658
- // archon may change any — same gate as settings).
662
+ // wizard's OPTIONAL creation steps after creation. Body: any subset of
663
+ // { description, detail, modules }. rank: any authenticated builder (own
664
+ // resource; archon may change any — same gate as settings).
659
665
  //
660
666
  // THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the
661
667
  // project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is
@@ -668,19 +674,31 @@ module.exports = function provisioningRoutes() {
668
674
  // answers live on the platform row until a catalog row materializes (the
669
675
  // provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that
670
676
  // reads `description` to compute publishability and name what is still missing.
677
+ //
678
+ // `modules` rides the SAME route for the same reason (BV1.R23, task 1002342,
679
+ // ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs
680
+ // the same way back, and it lands on the same platform row under the same
681
+ // ownership gate. Only an ARRAY is a change — an omitted or null key leaves the
682
+ // column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
683
+ // a later edit. The always-on core stays unremovable by construction:
684
+ // badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
671
685
  router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
672
686
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
673
687
  if (!id) return;
674
688
  if (validateOrRespond(req, res, {
675
689
  description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
676
690
  detail: { type: 'object' },
691
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 },
677
692
  })) return;
678
693
  const body = req.body || {};
679
694
  const detailFault = badProjectDetail(body.detail);
680
695
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
681
- if (body.description === undefined && body.detail === undefined) {
696
+ const modulesFault = badModuleSelection(body.modules);
697
+ if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
698
+ const wantsModules = Array.isArray(body.modules);
699
+ if (body.description === undefined && body.detail === undefined && !wantsModules) {
682
700
  return res.fail('bad_detail', { status: 400,
683
- message: 'The body carries nothing to change — send description, detail, or both.' });
701
+ message: 'The body carries nothing to change — send description, detail, modules, or any mix.' });
684
702
  }
685
703
  try {
686
704
  const inst = await provisioning.getInstanceById(pool, id);
@@ -692,12 +710,18 @@ module.exports = function provisioningRoutes() {
692
710
  const patch = {};
693
711
  if (body.description !== undefined) patch.description = provisioning.normalizeDescription(body.description);
694
712
  if (body.detail !== undefined) patch.detail = body.detail;
713
+ if (wantsModules) patch.modules = body.modules;
695
714
  const updated = await provisioning.updateInstanceDetail(pool, id, patch);
696
715
  await provisioning.recordEvent(pool, {
697
716
  instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'detail',
698
717
  detail: `project detail changed: ${Object.keys(patch).join(', ')}`,
699
718
  actor,
700
719
  }).catch(() => {});
720
+ // Re-take the catalog projection (task 1003576, ADR 0246) — this is THE write the
721
+ // R18 proof caught going nowhere. A description added here has to reach the map, and
722
+ // one CLEARED here has to leave it, which is why the hub assigns `description`
723
+ // rather than COALESCEing it.
724
+ await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
701
725
  res.json({
702
726
  ok: true,
703
727
  description: updated.description || null,
@@ -809,6 +833,11 @@ module.exports = function provisioningRoutes() {
809
833
  detail: `settings changed: ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(', ')}`,
810
834
  actor,
811
835
  }).catch(() => {});
836
+ // The door the owner just set is what the map draws this project's orb from
837
+ // (planet / star / black hole), so the catalog projection is re-taken here as well
838
+ // as on the detail write (task 1003576, ADR 0246). Unlike the push below this
839
+ // needs no restart and no reachable shape — it is the PLATFORM's own row.
840
+ await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
812
841
  }
813
842
  // The push (task 1003140). The row is now the platform's truth, but a
814
843
  // RUNNING instance reads its settings from web.env through a pack memoized
@@ -1054,7 +1083,12 @@ module.exports = function provisioningRoutes() {
1054
1083
  // Record BEFORE enqueueing: the runner reads the row's domain, so the intent
1055
1084
  // must never race ahead of the column it wires. The release queue rides the
1056
1085
  // SAME statement — see updateInstanceDomain.
1057
- await provisioning.updateInstanceDomain(pool, id, domain, releasing);
1086
+ const rewired = await provisioning.updateInstanceDomain(pool, id, domain, releasing);
1087
+ // The catalog is keyed on ORIGIN, so an address change moves the project's row (task
1088
+ // 1003576, ADR 0246). `retireOrigin` takes the abandoned one off the map in the same
1089
+ // call — a row left `active` at a hostname the project no longer answers on is the
1090
+ // ADR 0181 §2 leak, in the catalog instead of in DNS.
1091
+ await catalogBridge.publishInstanceToCatalog(pool, rewired, { retireOrigin: current }).catch(() => {});
1058
1092
  let queued = false;
1059
1093
  if (inst.status === 'active') {
1060
1094
  const { intent, created } = await provisioning.enqueueIntent(pool, id, 'domain-attach', 'api:self');
@@ -263,24 +263,27 @@ await ta('recordDnsNote / clearErrorNote are NARROW single-column updates — no
263
263
  console.log('\nPer-project settings (task 1003040):');
264
264
 
265
265
  t('effectiveSettings: defaults for a bare/empty row = today\'s behavior; stored keys overlay; junk falls back', () => {
266
- assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
267
- assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
268
- assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public' });
266
+ assert.deepEqual(P.effectiveSettings(null), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
267
+ assert.deepEqual(P.effectiveSettings({}), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
268
+ assert.deepEqual(P.effectiveSettings({ settings: {} }), { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' });
269
269
  assert.deepEqual(
270
270
  P.effectiveSettings({ settings: { platform_visibility: 'gated' } }),
271
- { platform_visibility: 'gated', joinability: 'apply', visibility: 'public' },
271
+ { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full' },
272
272
  'a stored key overlays only itself'
273
273
  );
274
274
  assert.deepEqual(
275
- P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted' } }),
276
- { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public' },
275
+ P.effectiveSettings({ settings: { platform_visibility: 'bogus', joinability: 'invite_only', future_key: 'x', visibility: 'unlisted', join_grant: 'everything' } }),
276
+ { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full' },
277
277
  'off-vocabulary values fall back to the default; unknown keys never leak out'
278
278
  );
279
279
  assert.deepEqual(P.effectiveSettings({ settings: ['not', 'an', 'object'] }),
280
- { platform_visibility: 'public', joinability: 'apply', visibility: 'public' }, 'a non-object settings value reads as defaults');
280
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full' }, 'a non-object settings value reads as defaults');
281
281
  // the join door reads back like any other policy key (task 1002324)
282
282
  assert.deepEqual(P.effectiveSettings({ settings: { visibility: 'stealth' } }),
283
- { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth' }, 'a stored door overlays only itself');
283
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full' }, 'a stored door overlays only itself');
284
+ // what a public join grants is a policy key like any other (task 1002331)
285
+ assert.deepEqual(P.effectiveSettings({ settings: { join_grant: 'view' } }),
286
+ { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'view' }, 'the grant overlays only itself');
284
287
  });
285
288
 
286
289
  t('SETTINGS_VOCAB / DEFAULT_SETTINGS: every default is on its own vocabulary (lockstep guard)', () => {
@@ -1368,6 +1368,21 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1368
1368
  #view-manage .visOpt:has(input:checked) b::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
1369
1369
  #view-manage .visOpt:focus-within{outline:2px solid var(--accent);outline-offset:3px;}
1370
1370
  #view-manage #mVisRuns b,#view-manage #mJoinRuns b{color:var(--ok);font-weight:500;}
1371
+ /* the project's extras (task 1002342): the creation picker's own band, asked
1372
+ again after the project exists. Stacked full width rather than side by side
1373
+ like the choice bands above, because each row carries a sentence saying what
1374
+ the part is for — and because these are independent facts, not one choice. */
1375
+ #view-manage .modBand{display:grid;border-top:1px solid var(--rule);border-bottom:1px solid var(--rule);}
1376
+ #view-manage .modOpt{display:grid;grid-template-columns:auto 1fr;gap:2px 10px;margin:0;padding:16px 0 18px;border:0;border-top:1px solid var(--rule-soft);border-radius:0;background:none;text-align:left;cursor:pointer;}
1377
+ #view-manage .modOpt:first-child{border-top:0;}
1378
+ #view-manage .modOpt::before{content:"";grid-column:1;grid-row:1;width:7px;height:7px;margin-top:6px;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
1379
+ #view-manage .modOpt>b,#view-manage .modOpt>small{grid-column:2;min-width:0;}
1380
+ #view-manage .modOpt[aria-pressed="true"]::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
1381
+ #view-manage .modOpt b{font-family:var(--font-body);font-weight:500;font-size:15px;color:var(--ink);}
1382
+ #view-manage .modOpt small{font-family:var(--font-body);font-size:13px;font-weight:400;line-height:1.45;color:var(--ink-faint);max-width:62ch;}
1383
+ #view-manage .modOpt:hover::before{border-color:var(--ink-faint);}
1384
+ #view-manage .modOpt[disabled]{cursor:default;opacity:.55;}
1385
+ #view-manage .modOpt:focus-visible{outline:2px solid var(--accent);outline-offset:3px;}
1371
1386
  /* ── its planet (task 1003347; direction record §16, v1 the bands): the preview
1372
1387
  planet left; four bands right — shape · material · style as hairline cells of
1373
1388
  one band, a word each, the chosen one carrying the dot; the colour as five
@@ -829,6 +829,26 @@ summary{min-height:24px;padding:3px 0;}
829
829
  </details>
830
830
  </div>
831
831
 
832
+ <!-- live since task 1002342 (BV1.R23): the way back into the creation
833
+ wizard's module picker. Step 4 of the wizard is skippable, and an
834
+ optional step is a default rather than a dead end only if there is a
835
+ way back — the same argument the About step's PATCH …/detail was built
836
+ on, which is why this section saves through THAT route and not the
837
+ settings channel beside it: a module set is a declaration on the
838
+ platform row, not a policy a running instance obeys (ADR 0240 §3,
839
+ ADR 0245). So, unlike its three siblings above, nothing is pushed to
840
+ the project and nothing restarts — and there is no "what your project
841
+ runs" read-back line, because there is nothing to read back. The fold
842
+ is a SIBLING of the volatile slot so no re-render can drop it. -->
843
+ <div class="card mSec" id="mModules">
844
+ <h2>Its extras</h2>
845
+ <div id="mMods"><p class="finePrint">Reading the project…</p></div>
846
+ <details class="finePrint" id="mModsWhat">
847
+ <summary>What an extra is — and what is always on</summary>
848
+ <p>Extras are the optional parts a project can run — a shared dev box, a Discord mirror, and the rest. You picked a set when you created the project, or took the suggested set by skipping that step; this is where you change your mind. Switching one on or off saves straight away and changes nothing that is already running: nothing restarts, and no work is lost either way. The parts every project runs — the platform itself, the way it tracks work, the builders’ hall — were never part of the choice, so they are not listed here and cannot be switched off.</p>
849
+ </details>
850
+ </div>
851
+
832
852
  <!-- the planet picker (task 1003347; direction record §16, v1 the bands): the
833
853
  owner's own choice of how the platform draws this project — the sky,
834
854
  the list, this page's head and the card read the same row. The pick is
@@ -1028,6 +1048,14 @@ summary{min-height:24px;padding:3px 0;}
1028
1048
  <p class="lede">Optional — skip it and you get the suggested set below, shaped by what you just told us. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
1029
1049
  <form data-next="5">
1030
1050
  <div class="wizField"><span id="modLbl">Extras for this project</span>
1051
+ <!-- the catalog search (task 1002340, BV1.R21). The wizard's own
1052
+ search idiom (#ghSearch): a plain field on one hairline, no
1053
+ new class and no icon. It filters the band below and NOTHING
1054
+ else — what is switched on is unaffected by what is on
1055
+ screen, and the line under it counts the ones it is hiding
1056
+ (ADR 0245). -->
1057
+ <input type="text" id="modQ" placeholder="Search extras…" autocomplete="off" aria-label="Search the extras by name" aria-describedby="modFilterNote">
1058
+ <div class="hint" id="modFilterNote" aria-live="polite"></div>
1031
1059
  <div class="typeCards" id="modCards" role="group" aria-labelledby="modLbl"></div>
1032
1060
  </div>
1033
1061
  <div class="modWhy" id="modWhy" aria-live="polite"></div>
@@ -1177,6 +1205,9 @@ summary{min-height:24px;padding:3px 0;}
1177
1205
  <div id="liveProgress" role="status" aria-live="polite"></div>
1178
1206
  <h2 class="stepsHead">Your steps — the parts only you can play</h2>
1179
1207
  <ol class="humanSteps" id="humanSteps"></ol>
1208
+ <!-- the modules step was optional (task 1002342, BV1.R23) — one line
1209
+ saying where it lives afterwards, so a skip is never a dead end -->
1210
+ <p class="samePath" id="doneModules"></p>
1180
1211
  <p class="samePath">These are the same steps however a project is created — nothing here is special to this page.</p>
1181
1212
  </section>
1182
1213
  </div>
@@ -3262,10 +3293,52 @@ summary{min-height:24px;padding:3px 0;}
3262
3293
  for (var i = 0; i < rows.length; i++) if (rows[i].key === key) return rows[i].label;
3263
3294
  return key;
3264
3295
  }
3296
+ /* ── the catalog search (task 1002340, BV1.R21) ──
3297
+ Lexical and deliberately dumb: it matches the words the owner can already
3298
+ see. Search by MEANING ("somewhere to chat") is a different feature and is
3299
+ deferred (idea 1000552), so nothing here pretends to understand a query.
3300
+
3301
+ It is a VIEW over the rows and never an edit to the answer (ADR 0245):
3302
+ filtering paints fewer rows, and state.modules does not move. That is what
3303
+ lets a switched-on module be off screen without being switched off — the
3304
+ count under the field says so rather than leaving it to be discovered. */
3305
+ var moduleQuery = ''; /* what is typed in the search box; never saved to the draft */
3306
+ /* A query as the terms to match: lower-cased, split on whitespace, empties
3307
+ dropped — so a box holding only spaces is the same as an empty one. */
3308
+ function searchTerms(q) {
3309
+ return String(q == null ? '' : q).toLowerCase().split(/\s+/).filter(function (t) { return t.length > 0; });
3310
+ }
3311
+ /* Does one catalog row answer this search? Every term must appear somewhere in
3312
+ the row's own words — its name, its one-line summary, or its key — as a
3313
+ substring, case-insensitively. AND across terms, so adding a word narrows;
3314
+ substring, so a half-typed word still finds its module. */
3315
+ function moduleMatches(mod, terms) {
3316
+ if (!terms.length) return true;
3317
+ var hay = (((mod && mod.label) || '') + ' ' + ((mod && mod.summary) || '') + ' ' + ((mod && mod.key) || '')).toLowerCase();
3318
+ for (var i = 0; i < terms.length; i++) if (hay.indexOf(terms[i]) === -1) return false;
3319
+ return true;
3320
+ }
3321
+ /* The catalog rows the current search leaves on screen, in catalog order. */
3322
+ function visibleModules() {
3323
+ var terms = searchTerms(moduleQuery);
3324
+ return ((starterBundles && starterBundles.optional) || []).filter(function (m) { return moduleMatches(m, terms); });
3325
+ }
3326
+ /* The line under the search box. Silent with nothing typed — there is nothing
3327
+ to explain. Once a search is hiding rows it says how many are left AND how
3328
+ many extras are on, counting the hidden ones: a switched-on module a filter
3329
+ took off screen would otherwise read as switched off. */
3330
+ function filterNote(shown, total, onCount) {
3331
+ if (!searchTerms(moduleQuery).length) return '';
3332
+ var line = shown ? 'Showing ' + shown + ' of ' + total + '.' : 'No extras match what you typed.';
3333
+ return onCount
3334
+ ? line + ' ' + onCount + (onCount === 1 ? ' extra is' : ' extras are') + ' switched on, including any this search is hiding.'
3335
+ : line;
3336
+ }
3265
3337
  function renderModules() {
3266
3338
  ensureBundles();
3267
3339
  var band = $('modCards');
3268
3340
  var note = $('modCoreNote');
3341
+ $('modQ').hidden = !starterBundles; /* a search box over a list that is not there can only disappoint */
3269
3342
  if (!starterBundles) {
3270
3343
  /* nothing yet. While the read is still in flight that is a blank band for
3271
3344
  a moment; only a read that ANSWERED badly gets the clause, so a slow
@@ -3273,6 +3346,7 @@ summary{min-height:24px;padding:3px 0;}
3273
3346
  band.innerHTML = '';
3274
3347
  $('modWhy').innerHTML = '';
3275
3348
  $('modWhy').classList.remove('on');
3349
+ $('modFilterNote').textContent = '';
3276
3350
  note.textContent = bundlesFailed
3277
3351
  ? 'We couldn’t load the list of extras just now. Your project is created with everything it needs either way, and you can add extras from its settings.'
3278
3352
  : '';
@@ -3280,11 +3354,13 @@ summary{min-height:24px;padding:3px 0;}
3280
3354
  return;
3281
3355
  }
3282
3356
  var on = pickedModules();
3283
- band.innerHTML = (starterBundles.optional || []).map(function (m) {
3357
+ var offered = visibleModules();
3358
+ band.innerHTML = offered.map(function (m) {
3284
3359
  return '<button type="button" class="tpill" data-mod="' + esc(m.key) + '" aria-pressed="'
3285
3360
  + (on.indexOf(m.key) === -1 ? 'false' : 'true') + '"><span><b>' + esc(m.label)
3286
3361
  + '</b><small>' + esc(m.summary) + '</small></span></button>';
3287
3362
  }).join('');
3363
+ $('modFilterNote').textContent = filterNote(offered.length, (starterBundles.optional || []).length, on.length);
3288
3364
  /* The tailoring, said out loud (task 1002341). A preselect an owner cannot
3289
3365
  see the reason for is one they have to audit, which is the thing ADR 0237
3290
3366
  says is worse than no preselect at all. Rendered as its own block above the
@@ -3317,6 +3393,13 @@ summary{min-height:24px;padding:3px 0;}
3317
3393
  renderModules();
3318
3394
  saveDraft();
3319
3395
  });
3396
+ /* Typing repaints the band and nothing else (task 1002340): no read (the
3397
+ catalog is already here) and no saveDraft — a search is not an answer, so
3398
+ it must not be able to turn an untouched picker into an answered one. */
3399
+ $('modQ').addEventListener('input', function (ev) {
3400
+ moduleQuery = (ev && ev.target && ev.target.value) || '';
3401
+ renderModules();
3402
+ });
3320
3403
  /* Next off this panel CONFIRMS what is on screen — the owner looked at the
3321
3404
  set and accepted it, which is a different fact from never being asked. */
3322
3405
  root.querySelector('#panel-modules form').addEventListener('submit', function () {
@@ -4011,6 +4094,10 @@ summary{min-height:24px;padding:3px 0;}
4011
4094
  $('doneLede').innerHTML = 'Provisioning is requested for <strong>' + esc(state.name || state.slug) + '</strong>' +
4012
4095
  (adopt ? ' — adopting your existing repo' : '') +
4013
4096
  '. The platform stands up the server, database, and address on its own — watch it right here, or any time on <a href="?view=mine">your projects page</a>.';
4097
+ /* the picker's later half (task 1002342, BV1.R23). The modules step is
4098
+ skippable, so this panel names where it lives afterwards rather than
4099
+ leaving an owner to go looking for it. */
4100
+ $('doneModules').innerHTML = 'Add or remove modules later in ' + projectPageLink('the project’s settings') + '.';
4014
4101
  renderSteps();
4015
4102
  }
4016
4103
 
@@ -4024,7 +4111,14 @@ summary{min-height:24px;padding:3px 0;}
4024
4111
  var a = ev.target.closest && ev.target.closest('a[href^="?view="]');
4025
4112
  if (!a || !root.contains(a)) return;
4026
4113
  ev.preventDefault();
4027
- setView((a.getAttribute('href').match(/view=(\w+)/) || [])[1], true);
4114
+ var href = a.getAttribute('href');
4115
+ var to = (href.match(/view=(\w+)/) || [])[1];
4116
+ /* a manage link names its project in the query, and setView reads manageId
4117
+ rather than the href — so the id has to be handed over or every link on
4118
+ this panel lands on a manage page with no project (task 1002342) */
4119
+ var mid = (href.match(/[?&]id=([^&]*)/) || [])[1];
4120
+ if (to === 'manage' && mid) return goToManage(decodeURIComponent(mid));
4121
+ setView(to, true);
4028
4122
  });
4029
4123
 
4030
4124
  /* One delegated listener — renderSteps swaps innerHTML, so handlers live on the list. */
@@ -5439,6 +5533,9 @@ summary{min-height:24px;padding:3px 0;}
5439
5533
  guard because its card holds a textarea the in-flight tick must
5440
5534
  never repaint out from under a typing owner. */
5441
5535
  if (!keepRepo) renderPublish(inst);
5536
+ /* the extras band (task 1002342) rides the same guard: its own toggles
5537
+ are the only thing that should ever repaint it, never the tick */
5538
+ if (!keepRepo) renderMods(inst);
5442
5539
  })
5443
5540
  .catch(function (err) {
5444
5541
  var m = String(err && err.message);
@@ -6219,6 +6316,128 @@ summary{min-height:24px;padding:3px 0;}
6219
6316
  if (t && t.hasAttribute('data-join-save')) doJoinSave();
6220
6317
  });
6221
6318
 
6319
+ /* ── the project's extras (task 1002342, BV1.R23) ──
6320
+ The way back into the creation wizard's module picker. Step 4 is skippable,
6321
+ and an optional step is a default rather than a dead end only if there is a
6322
+ way back — the argument PATCH …/detail was built on for the About step
6323
+ beside it, which is why this saves through THAT route rather than the
6324
+ settings channel its three neighbours use: a module set is a declaration on
6325
+ the platform row, not a policy a running instance obeys (ADR 0240 §3, ADR
6326
+ 0245). Nothing is pushed and nothing restarts, so this section has no "what
6327
+ your project runs" line — there is nothing to read back.
6328
+
6329
+ Two reads, and only one is new. The CURRENT selection rides in on the
6330
+ instance itself (publicInstance resolves it, so a project that skipped the
6331
+ picker reads as its own starter bundle and this page never re-derives that
6332
+ rule); the CATALOG — what may be chosen, and the owner-facing line for each
6333
+ — is the same public GET /provisioning/starter-bundles the wizard reads.
6334
+
6335
+ A toggle IS the save. The band is a list of independent facts rather than
6336
+ one choice, so a Save button would only add a way to lose one. The write
6337
+ sends the WHOLE list because that is what the column stores, and the band
6338
+ repaints from the server's own answer — never from the list we sent — so it
6339
+ can never show a set the platform did not accept. ── */
6340
+
6341
+ var modsCatalog = null; /* { core, optional } — the public catalog, read once */
6342
+ var modsPromise = null;
6343
+ var modsFailed = false; /* asked and did not get it; distinct from not yet asked */
6344
+ var modsBusy = false; /* a write is in flight — the band is inert until it answers */
6345
+
6346
+ function modsSlot(html) { $('mMods').innerHTML = html; }
6347
+
6348
+ /* Lazy and memoised, the wizard picker's rule: /projects serves four views
6349
+ from one file and the map has no business paying for this read. */
6350
+ function ensureModsCatalog() {
6351
+ if (modsPromise) return modsPromise;
6352
+ modsPromise = fetch(API + '/provisioning/starter-bundles', { credentials: 'same-origin' })
6353
+ .then(function (res) { return res.ok ? res.json() : null; })
6354
+ .then(function (data) {
6355
+ if (data && data.optional) modsCatalog = data; else modsFailed = true;
6356
+ if (manageInst) renderMods(manageInst);
6357
+ })
6358
+ .catch(function () { modsFailed = true; if (manageInst) renderMods(manageInst); });
6359
+ return modsPromise;
6360
+ }
6361
+
6362
+ /* What this project runs today, straight off the instance read — never
6363
+ re-derived here, or a skipped picker would read differently on two pages. */
6364
+ function modsSelected(inst) {
6365
+ return ((inst && inst.modules && inst.modules.selected) || []).slice();
6366
+ }
6367
+
6368
+ function renderMods(inst) {
6369
+ ensureModsCatalog();
6370
+ if (!modsCatalog) {
6371
+ /* a read still in flight is a quiet slot; only one that ANSWERED badly
6372
+ gets the clause, so a slow network never reports a break that isn't */
6373
+ modsSlot(modsFailed
6374
+ ? '<p class="finePrint">We couldn’t load the list of extras just now — refresh in a moment. Nothing about your project has changed.</p>'
6375
+ : '<p class="finePrint">Reading the project…</p>');
6376
+ return;
6377
+ }
6378
+ var on = modsSelected(inst);
6379
+ var core = (modsCatalog.core || []).length;
6380
+ modsSlot('<div class="modBand" id="mModBand" role="group" aria-label="Extras">' +
6381
+ (modsCatalog.optional || []).map(function (m) {
6382
+ return '<button type="button" class="modOpt" data-mod="' + esc(m.key) + '" aria-pressed="' +
6383
+ (on.indexOf(m.key) === -1 ? 'false' : 'true') + '"' + (modsBusy ? ' disabled' : '') +
6384
+ '><b>' + esc(m.label) + '</b><small>' + esc(m.summary) + '</small></button>';
6385
+ }).join('') + '</div>' +
6386
+ (core ? '<p class="finePrint">Every project also starts with the same ' + core +
6387
+ ' core parts — the platform itself, the way it tracks work, and the builders’ hall. They are always on, so they are not part of this choice.</p>' : '') +
6388
+ '<div class="projMsg" id="mModMsg" role="status" aria-live="polite"></div>');
6389
+ }
6390
+
6391
+ /* One toggle, sent as the whole list: the column stores a list, so a delta
6392
+ would have to be re-assembled somewhere and the two copies would drift. */
6393
+ function doModToggle(key) {
6394
+ if (!manageInst || !modsCatalog || modsBusy) return;
6395
+ var on = modsSelected(manageInst);
6396
+ var want = on.indexOf(key) === -1 ? on.concat([key]) : on.filter(function (k) { return k !== key; });
6397
+ /* CATALOG order — the order the picker paints and the server stores, so a
6398
+ re-read of what we just wrote can never look like a change */
6399
+ var next = (modsCatalog.optional || []).map(function (m) { return m.key; })
6400
+ .filter(function (k) { return want.indexOf(k) !== -1; });
6401
+ modsBusy = true;
6402
+ renderMods(manageInst);
6403
+ var box = $('mModMsg');
6404
+ box.className = 'projMsg'; box.textContent = 'Saving…';
6405
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
6406
+ method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
6407
+ body: JSON.stringify({ modules: next })
6408
+ }).then(function (res) {
6409
+ return jsonOrEmpty(res).then(function (data) {
6410
+ modsBusy = false;
6411
+ if (res.ok) {
6412
+ /* the platform's own answer, never the list we sent */
6413
+ manageInst.modules = data.modules || manageInst.modules;
6414
+ renderMods(manageInst);
6415
+ var ok = $('mModMsg');
6416
+ ok.className = 'projMsg ok';
6417
+ ok.textContent = 'Saved. Nothing on the project itself changes — there is nothing to restart.';
6418
+ return;
6419
+ }
6420
+ renderMods(manageInst); /* the band goes back to what the row still says */
6421
+ var e = apiError(data, res.status);
6422
+ var m = $('mModMsg');
6423
+ if (res.status === 401) { m.className = 'projMsg err'; m.innerHTML = signInAgain(); return; }
6424
+ m.className = 'projMsg err';
6425
+ m.textContent = e.message || 'The change could not be saved — try again in a moment.';
6426
+ });
6427
+ }).catch(function () {
6428
+ modsBusy = false;
6429
+ renderMods(manageInst);
6430
+ var m = $('mModMsg');
6431
+ m.className = 'projMsg err';
6432
+ m.textContent = 'Couldn’t reach the platform — check your connection and try again.';
6433
+ });
6434
+ }
6435
+
6436
+ $('mMods').addEventListener('click', function (ev) {
6437
+ var t = ev.target.closest && ev.target.closest('.modOpt[data-mod]');
6438
+ if (t && !t.disabled) doModToggle(t.getAttribute('data-mod'));
6439
+ });
6440
+
6222
6441
  /* ── on the map (task 1002324): the join door, ADR 0182 D1 — the third axis,
6223
6442
  beside "who can see it" (the project's own sign-in door) and "how people
6224
6443
  join" (its membership door). The two reads above, key for key: the CHOICE
@@ -30,7 +30,8 @@
30
30
  "out-new": { "auth": false, "url": "/projects?view=new", "expect": { "hidden": ["#qWrap"] } },
31
31
  "in-new-type": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-type"],"hidden":["#panel-name","#qWrap"]} },
32
32
  "in-new-about": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-detail"],"hidden":["#panel-type","#qWrap"]} },
33
- "in-new-modules": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900]], "expect": {"visible":["#panel-modules","#modCards .tpill[aria-pressed=\"true\"]","#modCoreNote"],"hidden":["#panel-detail","#qWrap"]} },
33
+ "in-new-modules": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900]], "expect": {"visible":["#panel-modules","#modQ","#modCards .tpill[aria-pressed=\"true\"]","#modCoreNote"],"hidden":["#panel-detail","#qWrap"]} },
34
+ "in-new-modules-search": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["fill","#modQ","discord"],["wait",200]], "expect": {"visible":["#panel-modules","#modQ"],"value":[["#modQ","discord"]],"count":[["#modCards .tpill",1],["#modCards .tpill[data-mod=\"discord\"]",1]],"text":[["#modFilterNote","Showing 1 of 3. 2 extras are switched on, including any this search is hiding."]]} },
34
35
  "in-new-home": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-home"],"hidden":["#panel-detail","#qWrap"]} },
35
36
  "in-new-address": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-address"],"hidden":["#panel-home","#qWrap"]} },
36
37
  "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
@@ -187,6 +187,7 @@ const STUB_VOCAB = {
187
187
  platform_visibility: ['public', 'gated'],
188
188
  joinability: ['open', 'apply', 'invite_only'],
189
189
  visibility: ['public', 'private', 'stealth'],
190
+ join_grant: ['view', 'apply', 'full'],
190
191
  planet_template: null, // the template set is long; shape is checked, not membership
191
192
  planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
192
193
  card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.532",
3
+ "version": "1.19.534",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.532",
9
+ "version": "1.19.534",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.532",
3
+ "version": "1.19.534",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -529,6 +529,15 @@ const KERNEL_FILES = [
529
529
  // KERNEL (already in the module-scope-map kernel key) and imports only the
530
530
  // kernel (instance-config) + node built-ins, so it joins the roster cleanly.
531
531
  'src/bongos/repo-info.js',
532
+ // task 1002331 (ADR 0182 D6 / ADR 0247): the project's composed join/apply door —
533
+ // a deterministic classifier over three policy values, the same category as
534
+ // route-rank-check.js and goal-scope-check.js above (a trust decision the doorway
535
+ // hands to modules so no module re-derives it). The doorway re-exports it, so
536
+ // leaving it off this roster reads as a kernel->non-kernel leak; it imports only
537
+ // branding.js and auth-admission.js — both on this roster — so joining costs
538
+ // nothing and buys the import-direction guarantee on the file that decides who
539
+ // may knock.
540
+ 'src/bongos/project-door.js',
532
541
  ];
533
542
 
534
543
  // Kernel files exempt from the no-module-keys scan (Check 10) because naming a
@@ -22,6 +22,13 @@ function federationHubOrigin() {
22
22
  }
23
23
 
24
24
  // Whether provisioning should federate this instance's sign-in through the hub.
25
+ //
26
+ // THIS GATE DECIDES A CREDENTIAL, NOT CATALOG MEMBERSHIP (task 1003576, ADR 0246). It used
27
+ // to decide both by accident — federateInstance was the only writer of the hub catalog — so
28
+ // a `standalone` project could never appear on the public map. That hop now runs at every
29
+ // provisioning write (modules/provisioning/catalog-bridge.js → the `project.catalog` port),
30
+ // for every hosting shape. What survives here is the narrow question this gate is actually
31
+ // about: whose web.env does the platform write, and therefore who can hold a client secret.
25
32
  // Dormant-by-default: true ONLY when the control plane is hub-configured (the
26
33
  // platform-identity module is present, a signing key is loadable, AND a hub origin
27
34
  // resolves), the instance is a CO-TENANT (the mothership-hosted shape whose web.env is