@bongos/core 1.19.515 → 1.19.517
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 +56 -36
- 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 +237 -229
- package/docs/copy-registry.json +387 -303
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/drachmae.html +3 -0
- package/modules/hall-ui/public/drachmae.js +138 -2
- package/modules/hall-ui/public/hall-kit.js +45 -7
- package/modules/hall-ui/public/profile-charts.js +6 -13
- package/modules/hall-ui/public/profile.css +6 -35
- package/modules/hall-ui/public/style.css +35 -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/hall_kit_charts.mjs +49 -0
- package/tests/hall_reference_world.mjs +129 -0
- 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,9 @@ 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)
|
|
1472
|
+
1.19.517 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1473
|
+
landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
|
|
1470
1474
|
---------------------------------------------------------------------------
|
|
1471
1475
|
```
|
|
@@ -102,6 +102,9 @@
|
|
|
102
102
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
103
103
|
<script src="/builders/palette.js" defer></script>
|
|
104
104
|
<script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
|
|
105
|
+
<!-- The chart primitive, for the treasury section (task 1003446). Before
|
|
106
|
+
drachmae.js, which reads window.OTBKit when it inserts that section. -->
|
|
107
|
+
<script src="/builders/hall-kit.js" defer></script>
|
|
105
108
|
<script src="/builders/drachmae.js?v=2026-09-02-drachmae-2" defer></script>
|
|
106
109
|
<script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
|
|
107
110
|
</body>
|
|
@@ -31,6 +31,15 @@
|
|
|
31
31
|
// The doc lives at docs/onboarding/, so relative links resolve against it.
|
|
32
32
|
const DOC_DIR = 'docs/onboarding/';
|
|
33
33
|
const BOUNTY_URL = '/api/gds/public/bounty-table';
|
|
34
|
+
// task 1003446: the treasury feed. NOT a new endpoint — this is the same
|
|
35
|
+
// /public/cost-summary the signed-out status page already draws as a
|
|
36
|
+
// cumulative line and the Work board already fetches to read ONE scalar off
|
|
37
|
+
// (`total_value_credits`). Its own route comment calls `daily[]` "the
|
|
38
|
+
// treasury chart's cumulative line"; the series was in the payload the whole
|
|
39
|
+
// time, and the stranger got the chart while the builder got the number.
|
|
40
|
+
const TREASURY_URL = '/api/gds/public/cost-summary';
|
|
41
|
+
const TREASURY_SECTION_ID = 'the-treasury';
|
|
42
|
+
const TREASURY_SECTION_TITLE = 'The treasury, so far';
|
|
34
43
|
const BOUNTY_SECTION_ID = 'security-bounty-rates';
|
|
35
44
|
const BOUNTY_SECTION_TITLE = 'Security bounty rates';
|
|
36
45
|
// The section the live table is inserted AHEAD of: the doc's closing links.
|
|
@@ -109,6 +118,129 @@
|
|
|
109
118
|
return true;
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
// ---- the treasury (task 1003446) -----------------------------------------
|
|
122
|
+
|
|
123
|
+
// One cumulative figure: the plot, the number it ends on, and the table twin
|
|
124
|
+
// that keeps it out of picture-only territory. `fmt` renders a value for a
|
|
125
|
+
// human; `rows` is the twin's own text, so the money and the credits can read
|
|
126
|
+
// differently without the chart knowing about either.
|
|
127
|
+
function treasuryFigure({ id, title, points, fmt, foot }) {
|
|
128
|
+
const kit = window.OTBKit;
|
|
129
|
+
const esc = window.OTBMdReader.escapeHtml;
|
|
130
|
+
if (!points.length) return '';
|
|
131
|
+
const last = points[points.length - 1];
|
|
132
|
+
const series = kit.timeStepSeriesHtml(points, {
|
|
133
|
+
x0: points[0].x,
|
|
134
|
+
x1: last.x,
|
|
135
|
+
yMax: last.value,
|
|
136
|
+
});
|
|
137
|
+
const { endX, endY } = series.geometry;
|
|
138
|
+
const day = (t) => new Date(t).toISOString().slice(0, 10);
|
|
139
|
+
// Every 7th day plus the last: a twin with one row per calendar day since
|
|
140
|
+
// May would be ~120 rows behind a disclosure nobody finishes reading.
|
|
141
|
+
const twinRows = points.filter((p, i) => i % 7 === 0 || i === points.length - 1);
|
|
142
|
+
return `<figure class="pchart" id="${esc(id)}">
|
|
143
|
+
<figcaption class="pchart__title">${esc(title)}</figcaption>
|
|
144
|
+
${kit.plotSvgHtml({
|
|
145
|
+
series: series.body,
|
|
146
|
+
// No readout is wired here, so no focus stop and no dead crosshair.
|
|
147
|
+
interactive: false,
|
|
148
|
+
ariaLabel: `${title}: ${fmt(last.value)} as of ${day(last.x)}`,
|
|
149
|
+
label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
|
|
150
|
+
leftTick: day(points[0].x),
|
|
151
|
+
rightTick: day(last.x),
|
|
152
|
+
})}
|
|
153
|
+
<details class="pchart__twin">
|
|
154
|
+
<summary>Read it as a table</summary>
|
|
155
|
+
<table class="ledger pchart__table"><thead><tr><th>Day</th><th>${esc(title)}</th></tr></thead><tbody>
|
|
156
|
+
${twinRows.map((p) => `<tr><td>${esc(day(p.x))}</td><td>${esc(fmt(p.value))}</td></tr>`).join('')}
|
|
157
|
+
</tbody></table>
|
|
158
|
+
</details>
|
|
159
|
+
<p class="pchart__foot">${esc(foot)}</p>
|
|
160
|
+
</figure>`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The live treasury as a real section of the article, ahead of the rate card.
|
|
164
|
+
// Returns false when the summary can't be read: the prose stays and no empty
|
|
165
|
+
// panel or outline entry that leads nowhere is rendered — the same contract
|
|
166
|
+
// insertBountySection keeps.
|
|
167
|
+
async function insertTreasurySection(article) {
|
|
168
|
+
if (!window.OTBKit) {
|
|
169
|
+
console.warn('[drachmae] OTBKit missing — the treasury section needs the chart primitive');
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
let payload;
|
|
173
|
+
try {
|
|
174
|
+
const r = await fetch(TREASURY_URL, { credentials: 'same-origin' });
|
|
175
|
+
if (!r.ok) throw new Error(`${TREASURY_URL} → ${r.status}`);
|
|
176
|
+
payload = await r.json();
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn('[drachmae] cost summary unavailable:', err.message);
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
const esc = window.OTBMdReader.escapeHtml;
|
|
182
|
+
const kit = window.OTBKit;
|
|
183
|
+
const currency = (window.__BRANDING__ && window.__BRANDING__.currency
|
|
184
|
+
&& window.__BRANDING__.currency.label) || 'credits';
|
|
185
|
+
const Currency = currency.charAt(0).toUpperCase() + currency.slice(1);
|
|
186
|
+
const usd = (n) => `$${Number(n || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
|
|
187
|
+
const num = (n) => Number(n || 0).toLocaleString();
|
|
188
|
+
|
|
189
|
+
// The server already ran generate_series, so every calendar day is present
|
|
190
|
+
// and a flat run is a real flat run rather than a missing sample.
|
|
191
|
+
const spend = kit.cumulateByDay(payload.daily, { valueOf: (r) => r.amount_usd });
|
|
192
|
+
const earned = kit.cumulateByDay(payload.daily_credits, { valueOf: (r) => r.credits });
|
|
193
|
+
// Both series empty is a treasury with nothing in it yet — one honest line,
|
|
194
|
+
// not two empty plots.
|
|
195
|
+
if (!spend.length && !earned.length) return false;
|
|
196
|
+
|
|
197
|
+
const figures = [
|
|
198
|
+
treasuryFigure({
|
|
199
|
+
id: 'treasury-spend',
|
|
200
|
+
title: 'Spent, cumulative',
|
|
201
|
+
points: spend,
|
|
202
|
+
fmt: usd,
|
|
203
|
+
foot: 'Every recorded cost — API, infra, compute, art — added up day by day.',
|
|
204
|
+
}),
|
|
205
|
+
treasuryFigure({
|
|
206
|
+
id: 'treasury-earned',
|
|
207
|
+
title: `${Currency} awarded, cumulative`,
|
|
208
|
+
points: earned,
|
|
209
|
+
fmt: num,
|
|
210
|
+
foot: `What the project has paid builders, in ${esc(currency)}, day by day.`,
|
|
211
|
+
}),
|
|
212
|
+
].filter(Boolean).join('');
|
|
213
|
+
|
|
214
|
+
// Where the money went, as the kit's bar series rather than a fourth
|
|
215
|
+
// hand-rolled one. Categories arrive sorted biggest-first.
|
|
216
|
+
const byCategory = (payload.by_category || []).slice(0, 8);
|
|
217
|
+
const categories = byCategory.length
|
|
218
|
+
? `<h3 id="treasury-by-category">Where it went</h3>`
|
|
219
|
+
+ kit.barSeriesHtml(
|
|
220
|
+
byCategory.map((c) => ({ label: c.category, value: c.total_usd, valueLabel: usd(c.total_usd) })),
|
|
221
|
+
{ list: true, ariaLabel: 'Spend by category', valueWidth: '84px' },
|
|
222
|
+
)
|
|
223
|
+
: '';
|
|
224
|
+
|
|
225
|
+
const section = document.createElement('section');
|
|
226
|
+
section.innerHTML =
|
|
227
|
+
`<h2 id="${esc(TREASURY_SECTION_ID)}">${esc(TREASURY_SECTION_TITLE)}</h2>`
|
|
228
|
+
+ `<p>These are this project's real numbers, read from the server when you `
|
|
229
|
+
+ `opened this page. It has spent <strong>${esc(usd(payload.total_usd))}</strong> `
|
|
230
|
+
+ `and awarded <strong>${esc(num(payload.total_value_credits))} ${esc(currency)}</strong> `
|
|
231
|
+
+ `for the work that money bought.</p>`
|
|
232
|
+
+ `<div class="pcharts">${figures}</div>`
|
|
233
|
+
+ categories;
|
|
234
|
+
|
|
235
|
+
const bounty = article.querySelector(`#${BOUNTY_SECTION_ID}`);
|
|
236
|
+
const closing = [...article.querySelectorAll('h2')].find((h) => CLOSING_H2.test(h.textContent.trim()));
|
|
237
|
+
// Ahead of the rate card if it landed first, else ahead of the closing
|
|
238
|
+
// links, else appended: the doc keeps ending on "dig deeper" either way.
|
|
239
|
+
const before = (bounty && bounty.parentElement === article ? bounty : null) || closing;
|
|
240
|
+
if (before) article.insertBefore(section, before); else article.appendChild(section);
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
112
244
|
// The outline is read from the RENDERED headings rather than the parser's raw
|
|
113
245
|
// text, so `*not*` and a [link](…) in a heading arrive as plain words, and so
|
|
114
246
|
// the live section inserted above is simply one more heading in order.
|
|
@@ -276,9 +408,13 @@
|
|
|
276
408
|
article.innerHTML = parsed.html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
|
|
277
409
|
article.removeAttribute('aria-busy');
|
|
278
410
|
show('article');
|
|
279
|
-
// Awaited before the outline is built so
|
|
280
|
-
// scrollspy
|
|
411
|
+
// Awaited before the outline is built so both live sections get their
|
|
412
|
+
// entries and scrollspy targets; the article itself is already on screen by
|
|
413
|
+
// now. The rate card goes in FIRST so the treasury can land ahead of it by
|
|
414
|
+
// id — the doc then reads prose, then what actually happened, then the
|
|
415
|
+
// published rates, then "dig deeper".
|
|
281
416
|
await insertBountySection(article);
|
|
417
|
+
await insertTreasurySection(article);
|
|
282
418
|
addAnchors(article);
|
|
283
419
|
setupTocToggle();
|
|
284
420
|
setupScrollspy(renderToc(collectHeadings(article)));
|
|
@@ -663,6 +663,35 @@
|
|
|
663
663
|
return chartNums(values).map((v) => (run += v));
|
|
664
664
|
}
|
|
665
665
|
|
|
666
|
+
// Timestamped rows → [{ x, value }] ascending, one point per calendar day
|
|
667
|
+
// that HAS a row, value cumulative. The third consumer of this shape put it
|
|
668
|
+
// here (task 1003446): the profile's works and credits lines, and
|
|
669
|
+
// /drachmae's treasury. Two callers is a coincidence; three is a primitive.
|
|
670
|
+
//
|
|
671
|
+
// `dateOf`/`valueOf` are the caller's, because only the caller knows which
|
|
672
|
+
// column is the date — `shipped_at` on an activity row, `recorded_at` on a
|
|
673
|
+
// credits row, `day` on a gap-filled daily feed. A row whose date will not
|
|
674
|
+
// parse is DROPPED rather than counted at the epoch, where it would stretch
|
|
675
|
+
// the axis back to 1970 and flatten every real point against the floor.
|
|
676
|
+
function cumulateByDay(rows, opts = {}) {
|
|
677
|
+
const dateOf = opts.dateOf || ((r) => r && r.day);
|
|
678
|
+
const valueOf = opts.valueOf || ((r) => r && r.value);
|
|
679
|
+
const byDay = new Map();
|
|
680
|
+
for (const r of rows || []) {
|
|
681
|
+
const t = Date.parse(dateOf(r));
|
|
682
|
+
if (!Number.isFinite(t)) continue;
|
|
683
|
+
// The epoch-day floor — UTC midnight, so two rows on one day land on one
|
|
684
|
+
// point. Same value as setUTCHours(0,0,0,0) without a Date per row, and
|
|
685
|
+
// the same expression profile-charts.js's own `midnight` used before this
|
|
686
|
+
// moved, so no series shifts by a day.
|
|
687
|
+
const d = Math.floor(t / 86400000) * 86400000;
|
|
688
|
+
byDay.set(d, (byDay.get(d) || 0) + (Number(valueOf(r)) || 0));
|
|
689
|
+
}
|
|
690
|
+
const days = [...byDay.keys()].sort((a, b) => a - b);
|
|
691
|
+
const totals = cumulate(days.map((d) => byDay.get(d)));
|
|
692
|
+
return days.map((d, i) => ({ x: d, value: totals[i] }));
|
|
693
|
+
}
|
|
694
|
+
|
|
666
695
|
// The line geometry, separated from the markup so a chart that needs the
|
|
667
696
|
// maths without this SVG can take just this. Returns { points, x, y, nums }.
|
|
668
697
|
//
|
|
@@ -867,21 +896,30 @@
|
|
|
867
896
|
return { body, xs, base, py, max };
|
|
868
897
|
}
|
|
869
898
|
|
|
870
|
-
// The plot itself: the series a geometry built, on the axis, with the
|
|
871
|
-
//
|
|
872
|
-
//
|
|
873
|
-
//
|
|
899
|
+
// The plot itself: the series a geometry built, on the axis, with the two end
|
|
900
|
+
// ticks — and, when the caller wires a readout, the hidden crosshair it moves
|
|
901
|
+
// plus the `tabindex` that makes an arrow-key-walkable chart reachable by tab.
|
|
902
|
+
//
|
|
903
|
+
// `interactive: false` (task 1003446) drops BOTH of those, and the pairing is
|
|
904
|
+
// the point: a `role="img"` with a `tabindex="0"` is a focus stop a keyboard
|
|
905
|
+
// reader lands on with nothing to do there, and a crosshair nothing moves is
|
|
906
|
+
// markup that only looks like a feature. A plot whose whole reading is its
|
|
907
|
+
// aria-label and its table twin says so.
|
|
874
908
|
function plotSvgHtml(opts = {}) {
|
|
875
909
|
const { width, height, pad, base } = plotBox(opts);
|
|
910
|
+
const interactive = opts.interactive !== false;
|
|
876
911
|
const tick = (x, text, anchor) => (text
|
|
877
912
|
? `<text class="kit-plot__tick" x="${x}" y="${height - 6}"`
|
|
878
913
|
+ `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`
|
|
879
914
|
: '');
|
|
880
|
-
return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img"
|
|
915
|
+
return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img"`
|
|
916
|
+
+ (interactive ? ' tabindex="0"' : '')
|
|
881
917
|
+ ` aria-label="${esc(opts.ariaLabel || '')}">`
|
|
882
918
|
+ (opts.series || '')
|
|
883
919
|
+ `<line class="kit-plot__axis" x1="${pad.l}" y1="${base}" x2="${width - pad.r}" y2="${base}"></line>`
|
|
884
|
-
+
|
|
920
|
+
+ (interactive
|
|
921
|
+
? `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
|
|
922
|
+
: '')
|
|
885
923
|
+ (opts.label || '')
|
|
886
924
|
+ tick(pad.l, opts.leftTick)
|
|
887
925
|
+ tick(width - pad.r, opts.rightTick, 'end')
|
|
@@ -918,7 +956,7 @@
|
|
|
918
956
|
// charts (task 1003445) — the ONE place the hall draws a series.
|
|
919
957
|
// linePoints/cumulate are the maths on their own, for a chart that wants
|
|
920
958
|
// the geometry without this SVG (the profile's timeChart).
|
|
921
|
-
sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate,
|
|
959
|
+
sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
|
|
922
960
|
// plotted charts: the axis frame + the two series shapes that ride it
|
|
923
961
|
plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
|
|
924
962
|
// plotBox is exported so a caller mapping pointer x back into viewBox
|
|
@@ -101,20 +101,13 @@
|
|
|
101
101
|
// point; a row with no parsable date is skipped, not zeroed. The lookback
|
|
102
102
|
// clips this for drawing (clipToWindow) rather than re-accumulating, so the
|
|
103
103
|
// running total stays true.
|
|
104
|
+
// The grouping AND the running total are the kit's since task 1003446, when
|
|
105
|
+
// /drachmae's treasury became the third caller of this shape. This wrapper
|
|
106
|
+
// survives only to keep the `{ day, value }` key this page's callers and
|
|
107
|
+
// clipToWindow already read; the arithmetic has one home.
|
|
104
108
|
function cumulativeByDay(rows, valueOf, dateOf = (r) => r && r.shipped_at) {
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const t = Date.parse(dateOf(r));
|
|
108
|
-
if (!Number.isFinite(t)) continue;
|
|
109
|
-
const d = midnight(t);
|
|
110
|
-
byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
|
|
111
|
-
}
|
|
112
|
-
// The grouping is this function's; the running total is the kit's
|
|
113
|
-
// (task 1003445), so the hall has ONE accumulator rather than two that
|
|
114
|
-
// could disagree about what a hole in the series means.
|
|
115
|
-
const days = [...byDay.keys()].sort((a, b) => a - b);
|
|
116
|
-
const totals = window.OTBKit.cumulate(days.map((d) => byDay.get(d)));
|
|
117
|
-
return days.map((d, i) => ({ day: d, value: totals[i] }));
|
|
109
|
+
return window.OTBKit.cumulateByDay(rows, { dateOf, valueOf })
|
|
110
|
+
.map((p) => ({ day: p.x, value: p.value }));
|
|
118
111
|
}
|
|
119
112
|
|
|
120
113
|
// Clip a cumulative series to [windowStart, ∞) for drawing. The line must
|
|
@@ -119,41 +119,12 @@
|
|
|
119
119
|
(glass on the card, like settings' quote), the series in the accent and every
|
|
120
120
|
word in ink. The SVG scales with its card; the axis band is inside the
|
|
121
121
|
viewBox, so the card grows with its content and never scrolls. */
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
.
|
|
128
|
-
margin: 0;
|
|
129
|
-
padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
|
|
130
|
-
background: var(--glass);
|
|
131
|
-
border: var(--line) solid var(--glass-edge);
|
|
132
|
-
border-radius: var(--radius-sm);
|
|
133
|
-
min-width: 0;
|
|
134
|
-
}
|
|
135
|
-
.pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
|
|
136
|
-
.pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
|
|
137
|
-
/* The plot itself — the svg, the line, the columns, the axis, the crosshair,
|
|
138
|
-
the ticks — is OTBKit's `.kit-plot*` in style.css (task 1003445). It was
|
|
139
|
-
written twice in this file's own script, and the goal's next charts want it
|
|
140
|
-
again. The dataviz spec moved verbatim; what stays below is this page's
|
|
141
|
-
FIGURE: the caption, the live readout, the table twin and the foot. */
|
|
142
|
-
.pchart__twin { margin-top: var(--sp-hair); }
|
|
143
|
-
.pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
|
|
144
|
-
.pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
|
|
145
|
-
.pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
|
|
146
|
-
.pchart__table td, .pchart__table th { padding: 3px 6px; }
|
|
147
|
-
.pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
|
|
148
|
-
.pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
|
149
|
-
/* the craft mix: label, bar, count — a row is its own table row */
|
|
150
|
-
/* The craft mix is OTBKit.barSeriesHtml (task 1003445) — the .pmix family was
|
|
151
|
-
the hall's third copy of one horizontal bar. Only the vertical air around it
|
|
152
|
-
is this page's; the row, the track and the 24px floor are the kit's. */
|
|
153
|
-
.pchart .kit-bars { margin: var(--sp-hair) 0; }
|
|
154
|
-
@media (max-width: 720px) {
|
|
155
|
-
.pcharts { grid-template-columns: minmax(0, 1fr); }
|
|
156
|
-
}
|
|
122
|
+
/* The plot is OTBKit's `.kit-plot*` (task 1003445) and the FIGURE around it —
|
|
123
|
+
`.pcharts` / `.pchart` / `__title` / `__read` / `__twin` / `__table` /
|
|
124
|
+
`__foot` — moved to style.css beside it in task 1003446, when /drachmae's
|
|
125
|
+
treasury became its second consumer. The names did not change: they are
|
|
126
|
+
spelled into profile.js AND drachmae.js. What stays in this sheet is the one
|
|
127
|
+
thing only this page has, the lookback control below. */
|
|
157
128
|
|
|
158
129
|
/* the lookback control (task 1003489, owner request) — one segmented row above
|
|
159
130
|
the grid, scoping every chart. The pressed side takes the accent TINT the
|
|
@@ -2047,6 +2047,41 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2047
2047
|
.kit-plot__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
|
|
2048
2048
|
.kit-plot__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
|
|
2049
2049
|
|
|
2050
|
+
/* The FIGURE a plot sits in — a card one tonal step down from the panel (glass
|
|
2051
|
+
on the card, like settings' quote), its caption, its live readout, its
|
|
2052
|
+
<details> table twin and its foot. Moved here from profile.css in task
|
|
2053
|
+
1003446, when /drachmae's treasury became the second consumer; the `.pchart`
|
|
2054
|
+
names are unchanged because they are spelled into profile.js and drachmae.js.
|
|
2055
|
+
The kit does NOT emit these — a page composes its own figure and the kit
|
|
2056
|
+
draws the series inside it. */
|
|
2057
|
+
.pcharts {
|
|
2058
|
+
display: grid;
|
|
2059
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
2060
|
+
gap: var(--sp-sm);
|
|
2061
|
+
}
|
|
2062
|
+
.pchart {
|
|
2063
|
+
margin: 0;
|
|
2064
|
+
padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
|
|
2065
|
+
background: var(--glass);
|
|
2066
|
+
border: var(--line) solid var(--glass-edge);
|
|
2067
|
+
border-radius: var(--radius-sm);
|
|
2068
|
+
min-width: 0;
|
|
2069
|
+
}
|
|
2070
|
+
.pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
|
|
2071
|
+
.pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
|
|
2072
|
+
.pchart__twin { margin-top: var(--sp-hair); }
|
|
2073
|
+
.pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
|
|
2074
|
+
.pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
|
|
2075
|
+
.pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
|
|
2076
|
+
.pchart__table td, .pchart__table th { padding: 3px 6px; }
|
|
2077
|
+
.pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
|
|
2078
|
+
.pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
|
|
2079
|
+
/* The bar series inside a figure keeps the figure's own vertical air. */
|
|
2080
|
+
.pchart .kit-bars { margin: var(--sp-hair) 0; }
|
|
2081
|
+
@media (max-width: 720px) {
|
|
2082
|
+
.pcharts { grid-template-columns: minmax(0, 1fr); }
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2050
2085
|
.curve__kinds { display: flex; flex-direction: column; gap: 6px; margin-top: 16px; }
|
|
2051
2086
|
|
|
2052
2087
|
/* ---------- onboarding checklist ---------- */
|
|
@@ -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>
|