@bongos/core 1.19.514 → 1.19.516

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 (38) hide show
  1. package/.bongos-core.json +81 -36
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +22 -2
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +362 -6
  10. package/docs/api-reference.md +12 -2
  11. package/docs/copy-inventory.md +224 -218
  12. package/docs/copy-registry.json +357 -291
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
  15. package/modules/government/catalog.js +6 -0
  16. package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
  17. package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
  18. package/modules/lifecycle/mingle.js +337 -0
  19. package/modules/lifecycle/module.json +1 -0
  20. package/modules/lifecycle/routes/mingle.js +116 -0
  21. package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
  22. package/modules/provisioning/provisioning.js +24 -4
  23. package/modules/provisioning/routes/provisioning.js +59 -2
  24. package/modules/provisioning/starter-bundles.js +91 -0
  25. package/modules/public-landing/public/assets/cosmos.css +1 -1
  26. package/modules/public-landing/public/projects.html +230 -35
  27. package/modules/public-landing/public/projects.probes.json +2 -2
  28. package/modules/public-landing/public/projects.states.json +5 -4
  29. package/modules/ui-design/kit/serve.js +32 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/government_seed.mjs +35 -0
  34. package/tests/mingle.mjs +370 -0
  35. package/tests/projects_hub.mjs +72 -7
  36. package/tests/projects_hub_module_picker.mjs +369 -0
  37. package/tests/provisioning_module_selection.mjs +286 -0
  38. package/tests/provisioning_starter_bundles.mjs +131 -0
@@ -0,0 +1,116 @@
1
+ // modules/lifecycle/routes/mingle.js — the two switches, the introductions, and
2
+ // the decline (task 1003177 / C07, goal 1000075; criterion
3
+ // collab-mingle-two-switches, ADR 0187 §5).
4
+ //
5
+ // FIVE ROUTES, AND THE SHAPE IS THE POINT:
6
+ // GET /mingle — where I stand: BOTH switches, my open
7
+ // introductions, and the matching rule itself.
8
+ // PUT /mingle/opt-in — MY half of consent. Own-scoped, any-builder.
9
+ // PUT /mingle/project — the PROJECT's half. `mingle.manage`.
10
+ // POST /mingle/round — run a pairing round. `mingle.manage`.
11
+ // POST /mingle/pairs/:id/decide — accept or decline an introduction I am in.
12
+ //
13
+ // WHY GET /mingle ANSWERS WITH BOTH SWITCHES RATHER THAN ONE BOOLEAN. "You cannot
14
+ // mingle" with no reason is a dead end for the one person who could fix half of
15
+ // it. A builder who has opted in on an instance that never enabled mingle should
16
+ // see that it is the project's half that is off, and not be told to change a
17
+ // setting they already set.
18
+ //
19
+ // THE OPERATOR GATE IS AN ATOM, NOT A RANK. `requireRank` in a modules/*/routes
20
+ // gate is what BV1.R105 exists to remove, and tests/government_require_permission
21
+ // fails the build on a new one — the two files still spelling it that way are
22
+ // documented holdouts, not a pattern to copy. So this mints `mingle.manage`
23
+ // (modules/government/catalog.js) and gates on it.
24
+ //
25
+ // FLOOR: metic, by the `discord.channels.manage` analogue — configuring a
26
+ // project-wide social surface — and not archon. What makes that safe is the OTHER
27
+ // switch: enabling mingle only OFFERS it, and can never pair a builder who has not
28
+ // personally opted in, so this gate cannot put anyone in a room with a stranger.
29
+ //
30
+ // THE DECLINE ROUTE COSTS THE DECLINER NOTHING, and there is deliberately nothing
31
+ // here that could make it cost: no counter to bump, no karma or credit call, no
32
+ // cooldown to write. See mingle.js and lifecycle_007_mingle.sql, which say the
33
+ // same thing about the read and the schema.
34
+ //
35
+ // Reaches core only through the doorway (ADR 0083).
36
+
37
+ 'use strict';
38
+
39
+ const express = require('express');
40
+ const api = require('../../../src/module-api');
41
+ const mingle = require('../mingle');
42
+
43
+ const { asyncHandler, validateOrRespond, parseId } = api;
44
+
45
+ module.exports = function buildMingleRouter() {
46
+ const router = express.Router();
47
+
48
+ // rank: any-builder, and OWN-SCOPED — your own state, never someone else's.
49
+ // Who else has opted in to being introduced is a fact about them.
50
+ router.get('/mingle', api.requireBuilder, asyncHandler('GET /mingle', async (req, res) => {
51
+ const [state, pairs] = await Promise.all([
52
+ mingle.mingleStateFor(req.builder.id),
53
+ mingle.openPairsFor(req.builder.id),
54
+ ]);
55
+ return res.json({
56
+ mingle: state,
57
+ open_pairs: pairs,
58
+ // Served VERBATIM from the module. The criterion asks for cross-craft to be
59
+ // chosen deliberately and RECORDED; this is where it is read, and a
60
+ // restatement here could drift from the matcher that actually runs.
61
+ matching: mingle.matchingModel(),
62
+ });
63
+ }, { errorCode: 'mingle_read_failed' }));
64
+
65
+ // rank: any-builder, OWN-SCOPED. Keyed by the SESSION, never a body field —
66
+ // consent you can give on someone else's behalf is not consent.
67
+ router.put('/mingle/opt-in', api.requireBuilder, asyncHandler('PUT /mingle/opt-in', async (req, res) => {
68
+ if (validateOrRespond(req, res, {
69
+ opted_in: { required: true, type: 'boolean' },
70
+ })) return;
71
+ const row = await mingle.setOptIn(req.builder.id, req.body.opted_in === true);
72
+ return res.json({ opted_in: row.opted_in, updated_at: row.updated_at });
73
+ }, { errorCode: 'mingle_optin_failed' }));
74
+
75
+ // perm: mingle.manage (floor metic) — see the header. Offering the feature to
76
+ // the project is an operator act; it still cannot pair anyone who has not
77
+ // opted themselves in.
78
+ router.put('/mingle/project', api.requireBuilder, api.requirePermission('mingle.manage'), asyncHandler('PUT /mingle/project', async (req, res) => {
79
+ if (validateOrRespond(req, res, {
80
+ enabled: { required: true, type: 'boolean' },
81
+ })) return;
82
+ const out = await mingle.setProjectEnabled(req.body.enabled === true, req.builder.id);
83
+ return res.json(out);
84
+ }, { errorCode: 'mingle_project_switch_failed' }));
85
+
86
+ // perm: mingle.manage — running a round writes introductions for other people,
87
+ // so it is an operator action rather than something a builder does to themselves.
88
+ // Refuses on its own when the project switch is off (runMingleRound checks).
89
+ router.post('/mingle/round', api.requireBuilder, api.requirePermission('mingle.manage'), asyncHandler('POST /mingle/round', async (req, res) => {
90
+ if (validateOrRespond(req, res, {
91
+ limit: { type: 'integer', min: 1, max: mingle.MAX_ROWS },
92
+ })) return;
93
+ const out = await mingle.runMingleRound({ limit: req.body.limit || mingle.MAX_ROWS });
94
+ return res.json({ paired: out.paired, count: out.paired.length, skipped: out.skipped });
95
+ }, { errorCode: 'mingle_round_failed' }));
96
+
97
+ // rank: any-builder. The UPDATE itself requires the caller to be one of the two
98
+ // people in the pair, so a builder cannot answer an introduction they are not
99
+ // part of — enforced in SQL, not by a check here that a later caller could skip.
100
+ router.post('/mingle/pairs/:id/decide', api.requireBuilder, asyncHandler('POST /mingle/pairs/:id/decide', async (req, res) => {
101
+ const pairId = parseId(req, res, { param: 'id', code: 'bad_pair_id', positiveInt: true });
102
+ if (pairId === null) return;
103
+ if (validateOrRespond(req, res, {
104
+ decision: { required: true, type: 'string', enum: ['accepted', 'declined'] },
105
+ })) return;
106
+
107
+ const out = await mingle.decidePair(pairId, req.builder.id, req.body.decision);
108
+ if (out.error === 'bad_decision') return res.fail(400, 'bad_decision', 'decision must be accepted or declined');
109
+ // ONE answer for "no such pair", "already settled" and "not yours". Telling
110
+ // them apart would let a caller probe which pair ids exist and who is in them.
111
+ if (out.error) return res.fail(404, 'pair_not_found', 'no open introduction with that id for you');
112
+ return res.json({ pair: out.pair });
113
+ }, { errorCode: 'mingle_decide_failed' }));
114
+
115
+ return router;
116
+ };
@@ -0,0 +1,60 @@
1
+ -- provisioning_014_module_selection.sql — where the creation picker's chosen
2
+ -- module set lands (BV1.R20, task 1002339; goal 1000046 criterion
3
+ -- `proj-module-select`).
4
+ --
5
+ -- R19 (task 1002338, ADR 0237) declared the starter bundles and served them, and
6
+ -- closed with "where a chosen selection lands is R20's decision — there is still
7
+ -- no module-selection column on any table". This is that column.
8
+ --
9
+ -- ONE column, jsonb, NULLABLE — and the nullability is the contract, not an
10
+ -- oversight:
11
+ --
12
+ -- NULL the owner never answered. They skipped the picker, or the request was
13
+ -- filed by an API caller / the CLI that does not ask. The set they get
14
+ -- is resolved at READ time from the type's starter bundle
15
+ -- (starter-bundles.effectiveModules), so skipping yields exactly the
16
+ -- preselect the panel showed — the same declaration on both ends.
17
+ -- '[]' the owner answered "none of the opt-ins". A real choice, and a
18
+ -- different fact from never having been asked. A NOT NULL DEFAULT '[]'
19
+ -- would have collapsed the two and made a skip unreadable forever.
20
+ -- '[…]' the keys the owner picked.
21
+ --
22
+ -- This is the `detail` rule from provisioning_013 applied to a list: absent stays
23
+ -- absent, skipped stays skipped, and nothing is ever back-filled with a guess.
24
+ --
25
+ -- WHY NOT `detail` ITSELF. provisioning_013's comment named R19 as a reason the
26
+ -- column was jsonb, but ADR 0237 §2 then ruled the other way: `detail` holds the
27
+ -- INTERVIEW's answers under PROJECT_DETAIL_VOCAB, a closed map of scalar
28
+ -- vocabularies that `normalizeProjectDetail` filters key-by-value. A list of
29
+ -- module keys is not that shape — carrying it there would have meant teaching the
30
+ -- interview's normalizer, its 400-ing route guard and its read projection about a
31
+ -- value type none of them have, to store something the interview deliberately
32
+ -- does not ask (ADR 0237 §2: "the interview does not ask a second time").
33
+ --
34
+ -- WHY NOT `settings`. A setting is a POLICY the running instance obeys — it rides
35
+ -- web.env and queues a `settings-apply` intent plus a restart. A module selection
36
+ -- is a declaration made before the instance exists; there is nothing running to
37
+ -- apply it to.
38
+ --
39
+ -- WHAT THE COLUMN IS AND IS NOT. It is the owner's DECLARATION, held on the
40
+ -- platform row until a catalog row materializes — the same standing `type` and
41
+ -- `description` have (provisioning_007 / _013). It is NOT yet an input to the
42
+ -- control-plane runner: `provisioning_intents` carries no payload, and turning a
43
+ -- stored selection into the child instance's own config/modules.json is a
44
+ -- separate piece of work (see the ADR's Consequences).
45
+ --
46
+ -- A module selection is NEVER a publish precondition — ADR 0182 D4 fixes that bar
47
+ -- at identity alone, and ADR 0237 §6 restates it for bundles.
48
+ --
49
+ -- Additive + provisioning_-namespaced (ADR 0083 §5): ADD COLUMN only, so the
50
+ -- previous code release still runs against this schema. Idempotent: safe to re-run.
51
+
52
+ BEGIN;
53
+
54
+ ALTER TABLE provisioning_instances
55
+ ADD COLUMN IF NOT EXISTS modules jsonb;
56
+
57
+ COMMENT ON COLUMN provisioning_instances.modules IS
58
+ 'The opt-in modules the owner chose in the creation picker (BV1.R20), as a jsonb array of module keys. NULLABLE and three-valued: NULL = never answered (the picker was skipped, or the caller does not ask) and the set resolves at read time from the declared type''s starter bundle; ''[]'' = the owner chose none of them, which is a real answer; ''["dev-box",…]'' = what they picked. The always-on core is never listed here — it is the floor every project gets (starter-bundles.ALWAYS_ON_CORE), not a choice. Read through starter-bundles.effectiveModules, never raw.';
59
+
60
+ COMMIT;
@@ -12,6 +12,14 @@
12
12
  // unit-testable with a fake `{ query }`. The route factory passes the doorway pool.
13
13
  'use strict';
14
14
 
15
+ // The one require in this file, and it stays inside the module: starter-bundles.js
16
+ // is the pure declaration beside it (ADR 0237 — no I/O, no core import), so the
17
+ // "no core require / fake `{ query }`" property above is untouched. It is here
18
+ // because the module SELECTION a row carries (BV1.R20, task 1002339) is three-
19
+ // valued, and the value it resolves to when nobody answered is the type's starter
20
+ // bundle — so storage and the read projection both have to read that declaration.
21
+ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundles');
22
+
15
23
  // ---------------------------------------------------------------------------
16
24
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
17
25
  // ---------------------------------------------------------------------------
@@ -352,6 +360,13 @@ function publicInstance(row) {
352
360
  // missing" from a plain instance read, without a second endpoint.
353
361
  description: row.description || null,
354
362
  detail: projectDetail(row),
363
+ // The creation picker's module set (task 1002339): the always-on core, what
364
+ // the owner ends up with, and whether that came from their own toggles or
365
+ // from the type's starter bundle because they never answered. Resolved, not
366
+ // raw — a caller must never have to re-derive the skipped-means-the-bundle
367
+ // rule, which is the one way two surfaces would come to name different
368
+ // modules for the same project.
369
+ modules: effectiveModules(row),
355
370
  // The verification overlay (task 1002696, redesign direction §3): what the
356
371
  // heartbeat last OBSERVED, which is NOT what `status` claims. null = never
357
372
  // probed — a different claim from last_verified_up=false, and the UI renders
@@ -800,7 +815,7 @@ async function listAllInstances(db, { status } = {}) {
800
815
  // re-fetches the existing row and returns { created:false } so the ROUTE can
801
816
  // decide (same owner → idempotent re-request; different owner → 409). Never
802
817
  // silently reassigns ownership.
803
- async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail }) {
818
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail, modules }) {
804
819
  const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
805
820
  // Unknown/absent → 'greenfield', matching the column default. Falling back to the
806
821
  // SAFE mode matters: greenfield refuses to push over existing history (no --force
@@ -815,16 +830,21 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
815
830
  // updateInstanceDetail is the way back.
816
831
  const told = normalizeDescription(description);
817
832
  const answers = normalizeProjectDetail(detail);
833
+ // The creation picker's selection (task 1002339). NULL when the owner never
834
+ // answered — the column is nullable precisely so a skip stays readable as a
835
+ // skip, and effectiveModules then resolves the type's bundle at read time. An
836
+ // explicit empty array is a real answer and is stored as one.
837
+ const picked = normalizeModuleSelection(modules);
818
838
  const cost = costEstimateUsd({ hostingShape: shape, tier });
819
839
  try {
820
840
  const { rows } = await db.query(
821
841
  `INSERT INTO provisioning_instances
822
- (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail)
823
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
842
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail, modules)
843
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14::jsonb)
824
844
  RETURNING *`,
825
845
  [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
826
846
  dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType,
827
- told, JSON.stringify(answers)]
847
+ told, JSON.stringify(answers), picked === null ? null : JSON.stringify(picked)]
828
848
  );
829
849
  return { instance: rows[0], created: true };
830
850
  } catch (e) {
@@ -36,7 +36,7 @@ const auth = api;
36
36
  const { pool, validateOrRespond, parseId } = api;
37
37
  const provisioning = require('../provisioning');
38
38
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
39
- const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
39
+ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, allStarterBundles, optionalModules } = require('../starter-bundles');
40
40
  const { recommendationsForDetails } = require('../recommendations');
41
41
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
42
42
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -115,6 +115,30 @@ function badProjectDetail(detail) {
115
115
  return null;
116
116
  }
117
117
 
118
+ // The creation picker's module selection, validated LOUDLY (BV1.R20, task
119
+ // 1002339) — badProjectDetail's rule applied to a list. The strict body validator
120
+ // checks that `modules` is an array of strings; it cannot check MEMBERSHIP, and
121
+ // normalizeModuleSelection drops an unknown key silently on the way to storage.
122
+ // Silence is the wrong answer at the boundary: an API caller who names a module
123
+ // that does not exist, or one of the always-on / hub-only ones, has made a
124
+ // mistake and would otherwise be told nothing and handed a project without it.
125
+ // Returns null when clean, or a { code, message } to fail with.
126
+ function badModuleSelection(modules) {
127
+ if (modules === undefined || modules === null) return null;
128
+ if (!Array.isArray(modules)) {
129
+ return { code: 'bad_modules', message: 'modules must be an array of module keys.' };
130
+ }
131
+ const offered = OPTIONAL_MODULES.map((m) => m.key);
132
+ for (const key of modules) {
133
+ if (offered.includes(key)) continue;
134
+ if (ALWAYS_ON_CORE.includes(key)) {
135
+ return { code: 'bad_modules', message: `"${key}" is always on for every project — it is not one of the modules to choose.` };
136
+ }
137
+ return { code: 'bad_modules', message: `Unknown module "${key}" — the choices are: ${offered.join(', ')}.` };
138
+ }
139
+ return null;
140
+ }
141
+
118
142
  // Minimal HTML escaper for the callback landing page (no template engine here).
119
143
  function esc(s) {
120
144
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
@@ -463,10 +487,20 @@ module.exports = function provisioningRoutes() {
463
487
  // (ADR 0182 D4), and PATCH …/detail is the way back.
464
488
  description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
465
489
  detail: { type: 'object' },
490
+ // The creation picker's chosen module set (BV1.R20, task 1002339) — the
491
+ // opt-in keys, never the always-on core. ABSENT means the owner skipped
492
+ // the picker, which is a supported default: the row stores NULL and the
493
+ // read resolves the declared type's starter bundle, so a skip yields
494
+ // exactly the set the panel preselected. An explicit [] is the different
495
+ // answer "none of them". maxItems is a sanity ceiling well above a catalog
496
+ // that is three modules wide today.
497
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 },
466
498
  })) return;
467
499
  const body = req.body || {};
468
500
  const detailFault = badProjectDetail(body.detail);
469
501
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
502
+ const modulesFault = badModuleSelection(body.modules);
503
+ if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
470
504
  const slug = String(body.slug || '').trim().toLowerCase();
471
505
  if (!provisioning.isValidSlug(slug)) {
472
506
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
@@ -552,6 +586,9 @@ module.exports = function provisioningRoutes() {
552
586
  // The optional detail step's answers (task 1002334) — absent means the
553
587
  // owner skipped it, which is the supported default, not an error.
554
588
  description: body.description, detail: body.detail,
589
+ // The picker's selection (task 1002339) — absent for the same reason and
590
+ // with the same standing: skipped, and resolved from the bundle on read.
591
+ modules: body.modules,
555
592
  });
556
593
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
557
594
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
@@ -615,6 +652,16 @@ module.exports = function provisioningRoutes() {
615
652
  hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
616
653
  };
617
654
  }
655
+ // And the same for the picker's module set (task 1002339). Like `type` —
656
+ // and unlike detail — there is no mutation route yet (R23, task 1002342 is
657
+ // "add modules later"), so the receipt says the answer was kept rather
658
+ // than naming a way back that does not exist.
659
+ let modulesIgnored;
660
+ if (!created && body.modules !== undefined) {
661
+ modulesIgnored = {
662
+ hint: "a re-request never changes an existing instance's module selection",
663
+ };
664
+ }
618
665
  res.status(created ? 201 : 200).json({
619
666
  instance: ownerInstance(instance),
620
667
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
@@ -624,6 +671,7 @@ module.exports = function provisioningRoutes() {
624
671
  ...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
625
672
  ...(typeIgnored ? { type_ignored: typeIgnored } : {}),
626
673
  ...(detailIgnored ? { detail_ignored: detailIgnored } : {}),
674
+ ...(modulesIgnored ? { modules_ignored: modulesIgnored } : {}),
627
675
  });
628
676
  } catch (err) {
629
677
  log.error('[provisioning] POST /provisioning/instances', err);
@@ -1280,8 +1328,17 @@ module.exports = function provisioningRoutes() {
1280
1328
  // rank: public — the signed-out hub wizard (/projects?view=new) reads it while
1281
1329
  // choosing a type, exactly like onboard-plan above; no write, no secret, and the
1282
1330
  // module catalog it names is already public at GET /public/modules.
1331
+ //
1332
+ // `optional` joined the payload for R20's picker (task 1002339): the opt-in
1333
+ // catalog with the owner-facing label + one-line summary each row renders. It
1334
+ // lives here rather than at a module-catalog route because the two things a
1335
+ // picker needs — what is preselected and what may be chosen — have to arrive
1336
+ // in ONE read of ONE declaration, or the panel can preselect a key it has no
1337
+ // row for. The alternatives were both worse: `GET /modules` needs a session
1338
+ // (this wizard is read signed-out) and `GET /public/modules` serves booleans,
1339
+ // not copy. Additive — `core` and `bundles` keep their shape.
1283
1340
  router.get('/provisioning/starter-bundles', (req, res) => {
1284
- res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
1341
+ res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles(), optional: optionalModules() });
1285
1342
  });
1286
1343
 
1287
1344
  // GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>
@@ -70,6 +70,42 @@ const ALWAYS_ON_CORE = Object.freeze([
70
70
  // refuse them by construction rather than by anyone remembering.
71
71
  const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
72
72
 
73
+ // The OPT-IN catalog as the creation picker renders it (BV1.R20, task 1002339):
74
+ // every module an owner may actually choose, in the order the picker lists them,
75
+ // with copy written for someone who has met none of them.
76
+ //
77
+ // WHY THE COPY IS DECLARED HERE and not read off the manifests. `module.json`'s
78
+ // `title` + `description` are written for a builder reading a module list —
79
+ // "Outbound ship broadcast + the inbound #ideas/#bugs bot + role-sync … (ADR
80
+ // 0033)" is true and unusable on a creation screen. The hub's own catalog read
81
+ // (`GET /modules`) also needs a session, and this picker is read by a signed-out
82
+ // wizard. So the same decision ADR 0237 §3 already made for the bundle keys
83
+ // applies to the choices: DECLARE them, keep the file pure, and let
84
+ // tests/provisioning_starter_bundles.mjs be the link back to the manifests — it
85
+ // fails the build when the declared set drifts from the on-disk `default: false`
86
+ // modules, so a new opt-in module cannot ship without owner-facing copy.
87
+ const OPTIONAL_MODULES = Object.freeze([
88
+ Object.freeze({
89
+ key: 'agents',
90
+ label: 'Extra agents',
91
+ summary: 'More AI agents alongside the ones every project gets, for reading, research and review.',
92
+ }),
93
+ Object.freeze({
94
+ key: 'dev-box',
95
+ label: 'Dev boxes',
96
+ summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.',
97
+ }),
98
+ Object.freeze({
99
+ key: 'discord',
100
+ label: 'Discord',
101
+ summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.',
102
+ }),
103
+ ]);
104
+
105
+ // The opt-in keys alone, for the membership tests the normalizer runs. Derived,
106
+ // so the declaration above stays the single place a choice is written down.
107
+ const OPTIONAL_MODULE_KEYS = Object.freeze(OPTIONAL_MODULES.map((m) => m.key));
108
+
73
109
  // The curated presets, keyed by the declared project type (the closed vocabulary
74
110
  // owner-decided 2026-08-13 and mirrored in provisioning.PROJECT_TYPES + the
75
111
  // provisioning_007 CHECK). `label` and `summary` are the owner-facing copy the
@@ -150,10 +186,65 @@ function allStarterBundles() {
150
186
  return Object.keys(STARTER_BUNDLES).map(bundleForProjectType);
151
187
  }
152
188
 
189
+ // The opt-in catalog, as the picker reads it — fresh copies, so a caller may
190
+ // sort or splice without corrupting the frozen declaration.
191
+ function optionalModules() {
192
+ return OPTIONAL_MODULES.map((m) => ({ ...m }));
193
+ }
194
+
195
+ // A MODULE SELECTION as it may be STORED (BV1.R20, task 1002339): opt-in keys
196
+ // only, deduped, in catalog order. Everything else is dropped — an always-on
197
+ // module (it was never a choice), a hub-only one (the control plane's own), and
198
+ // anything not on the catalog.
199
+ //
200
+ // THREE-VALUED ON PURPOSE, and this is the whole contract:
201
+ //
202
+ // null the owner never answered — they skipped the picker, or the request
203
+ // predates it. The set they get is resolved at READ time from the type's
204
+ // starter bundle (effectiveModules below), the same way effectiveSettings
205
+ // applies a default it never wrote down. Absent stays absent.
206
+ // [] the owner answered "none of them" — a real choice, and a different
207
+ // fact from never having been asked.
208
+ // [...] what the owner picked.
209
+ //
210
+ // PURE. Mirrors normalizeProjectDetail's rule beside it: an off-vocabulary value
211
+ // is dropped rather than defaulted, because a module nobody chose is worse than
212
+ // no module.
213
+ function normalizeModuleSelection(input) {
214
+ if (!Array.isArray(input)) return null;
215
+ const asked = new Set(input.filter((k) => typeof k === 'string'));
216
+ return OPTIONAL_MODULE_KEYS.filter((k) => asked.has(k));
217
+ }
218
+
219
+ // What a row's modules READ as: the always-on floor, plus whatever the owner
220
+ // chose — or, when they never answered, the starter bundle for their declared
221
+ // type. `source` says which of the two it is, so a surface can tell a chosen
222
+ // empty set from an unanswered one without re-deriving the rule.
223
+ //
224
+ // This is what makes skipping the picker a supported default rather than a
225
+ // project with nothing on it: the flow's preselect and the server's fallback are
226
+ // the SAME declaration, so the owner gets exactly the set the panel showed them.
227
+ // PURE — takes a row-shaped `{ type, modules }`, touches no database.
228
+ function effectiveModules(row) {
229
+ const chosen = normalizeModuleSelection(row && row.modules);
230
+ const preset = bundleForProjectType(row && row.type);
231
+ const selected = chosen === null ? preset.bundle : chosen;
232
+ return {
233
+ core: [...ALWAYS_ON_CORE],
234
+ selected,
235
+ modules: [...ALWAYS_ON_CORE, ...selected],
236
+ source: chosen === null ? 'bundle' : 'chosen',
237
+ };
238
+ }
239
+
153
240
  module.exports = {
154
241
  ALWAYS_ON_CORE,
155
242
  HUB_ONLY_MODULES,
243
+ OPTIONAL_MODULES,
156
244
  STARTER_BUNDLES,
157
245
  bundleForProjectType,
158
246
  allStarterBundles,
247
+ optionalModules,
248
+ normalizeModuleSelection,
249
+ effectiveModules,
159
250
  };
@@ -1459,7 +1459,7 @@ p.sec-p{font-size:var(--fs-lede);font-weight:500;color:var(--ink-soft);line-heig
1459
1459
  /* the six-step strip as the rail: six hairlines, a 7px dot, one lit step on the
1460
1460
  accent with its halo, the number in the display voice, the name in the label
1461
1461
  face; a step already answered (role=button) stays a way back */
1462
- #view-new .steps{display:grid;grid-template-columns:repeat(6,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1462
+ #view-new .steps{display:grid;grid-template-columns:repeat(7,1fr);gap:0 10px;align-items:start;margin:0 0 40px;}
1463
1463
  #view-new .steps li{display:block;position:relative;border-top:1px solid var(--rule);padding:12px 0 0;gap:0;}
1464
1464
  #view-new .steps li::before{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;margin:0;border-radius:50%;border:1px solid var(--rule);background:var(--bg);}
1465
1465
  #view-new .steps .snum{display:block;width:auto;height:auto;border:0;border-radius:0;background:none;font-family:var(--font-display);font-weight:300;font-size:22px;letter-spacing:-.02em;line-height:1.1;color:var(--ink-faint);margin-bottom:2px;}