@bongos/core 1.19.515 → 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.
- package/.bongos-core.json +48 -28
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +12 -3
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +224 -218
- package/docs/copy-registry.json +357 -291
- package/docs/module-api-changelog.md +2 -0
- package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
- package/modules/provisioning/provisioning.js +24 -4
- package/modules/provisioning/routes/provisioning.js +59 -2
- package/modules/provisioning/starter-bundles.js +91 -0
- package/modules/public-landing/public/assets/cosmos.css +1 -1
- package/modules/public-landing/public/projects.html +230 -35
- package/modules/public-landing/public/projects.probes.json +2 -2
- package/modules/public-landing/public/projects.states.json +5 -4
- package/modules/ui-design/kit/serve.js +32 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub.mjs +72 -7
- package/tests/projects_hub_module_picker.mjs +369 -0
- package/tests/provisioning_module_selection.mjs +286 -0
- package/tests/provisioning_starter_bundles.mjs +131 -0
|
@@ -1467,5 +1467,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1467
1467
|
landed since 1.19.513 with no explicit bump. run 33796959752. (task 1002620)
|
|
1468
1468
|
1.19.515 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1469
1469
|
landed since 1.19.514 with no explicit bump. run 33797825811. (task 1002620)
|
|
1470
|
+
1.19.516 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1471
|
+
landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
|
|
1470
1472
|
---------------------------------------------------------------------------
|
|
1471
1473
|
```
|
|
@@ -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(
|
|
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;}
|