@bongos/core 1.19.508 → 1.19.510

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 (30) hide show
  1. package/.bongos-core.json +57 -32
  2. package/.claude/skills/new-project/SKILL.md +2 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +4 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0182-project-visibility-two-axes.md +1 -0
  9. package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +137 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +47 -6
  12. package/docs/api-reference.md +4 -3
  13. package/docs/copy-inventory.md +210 -207
  14. package/docs/copy-registry.json +297 -269
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/platform-identity/module.json +1 -1
  18. package/modules/platform-identity/platform-identity.js +30 -36
  19. package/modules/platform-identity/publish-gate.js +103 -0
  20. package/modules/platform-identity/routes/projects.js +31 -4
  21. package/modules/platform-identity/tests/platform-identity.mjs +7 -2
  22. package/modules/provisioning/routes/provisioning.js +70 -5
  23. package/modules/provisioning/starter-bundles.js +159 -0
  24. package/modules/public-landing/public/projects.html +157 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/projects_hub.mjs +62 -0
  29. package/tests/provisioning_starter_bundles.mjs +268 -0
  30. package/tests/publish_gate.mjs +167 -0
@@ -54,6 +54,22 @@
54
54
  cosmos vocabulary, scoped to #view-manage there (task 1003345) ── */
55
55
  #view-manage .empty{text-align:left;}
56
56
  #view-manage .empty p{margin-bottom:0;}
57
+ /* the publish gate's one field (task 1002335). The description is prose, so it
58
+ is a textarea — and it wears a skin scoped to THIS view, because the wizard's
59
+ field rules are scoped to #view-new and borrowing them would couple the two.
60
+ Same grammar as those fields, though: one bottom hairline that takes ink on
61
+ focus, never a box — the manage page is cosmos vocabulary throughout. */
62
+ #view-manage textarea{
63
+ width:100%;font-family:var(--font-body);font-size:15.5px;font-weight:400;color:var(--ink);line-height:1.5;
64
+ background:none;border:0;border-bottom:1px solid var(--rule);border-radius:0;padding:8px 0;outline:none;
65
+ resize:vertical;min-height:64px;display:block;
66
+ transition:border-color .15s ease;
67
+ }
68
+ #view-manage textarea::placeholder{color:var(--ink-faint);font-weight:400;}
69
+ #view-manage textarea:focus{border-bottom-color:var(--ink);box-shadow:none;}
70
+ /* the verdict leads the card — it is the answer, not fine print */
71
+ #view-manage .pubVerdict{font-size:15px;font-weight:500;line-height:1.55;color:var(--ink-soft);margin-bottom:14px;}
72
+ #view-manage .pubVerdict b{font-weight:700;color:var(--ink);}
57
73
 
58
74
  /* The bongo pair on the New-project view. Component-scoped custom properties,
59
75
  never :root (C1). The drum head is SKIN — using --bg-card would turn it black
@@ -690,6 +706,29 @@ summary{min-height:24px;padding:3px 0;}
690
706
  <div class="actions" id="mActs" hidden></div>
691
707
  </div>
692
708
 
709
+ <!-- live since task 1002335 (BV1.R16): the PUBLISH GATE. A project is on
710
+ the public map if and only if it has a name and a description — that
711
+ is the whole bar (ADR 0182 D4), identity only, deliberately not a
712
+ completeness check. Until it is met the project is DARK MATTER: off
713
+ the map and un-joinable. This card is the "name what is missing" half
714
+ of that decision, because a project that cannot be published and is
715
+ not told why is indistinguishable from a broken one.
716
+ The verdict rendered here is the SERVER's (instance.publish, derived
717
+ from the one gate definition platform-identity owns and hands over
718
+ the kernel seam) — never re-derived in the browser, which is how two
719
+ surfaces start telling one owner two different stories about one rule.
720
+ Hidden outright when the server sent no verdict: a platform with no
721
+ public map has nothing to say here, and inventing a local rule to
722
+ fill the silence is exactly what the seam exists to prevent. -->
723
+ <div class="card mSec" id="mPublish" hidden>
724
+ <h2>On the public map</h2>
725
+ <div id="mPub"><p class="finePrint">Reading the project…</p></div>
726
+ <details class="finePrint" id="mPubWhat">
727
+ <summary>What puts a project on the map</summary>
728
+ <p>Two things, and only two: a name — this project already has one — and a description of what it is. Modules, artwork, a finished setup interview: none of them are required, because the bar is there to keep empty shells off the map, not to make you finish anything. Until it is met the project is not listed publicly and nobody can ask to join it; the project itself runs exactly the same either way. Adding a description puts it on the map, and clearing one takes it back off.</p>
729
+ </details>
730
+ </div>
731
+
693
732
  <!-- its address (task 1003355): the bring-your-own-domain control, moved
694
733
  off the list row when a row shrank to view and manage. Its home is
695
734
  here, under the ledger's address fact. The section hides itself where
@@ -4577,6 +4616,22 @@ summary{min-height:24px;padding:3px 0;}
4577
4616
  : '<a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) + '">Give it an address on its own page</a>.');
4578
4617
  }
4579
4618
 
4619
+ /* the publish gate on the LIST card (task 1002335, BV1.R16). Only the refusal
4620
+ earns a line here: being on the map is the expected state, and a card that
4621
+ announces it for every healthy project is noise — while a project that is
4622
+ invisible and never told why reads as broken (ADR 0182 D4). It names what is
4623
+ missing in the server's own words and points at the one page that fixes it.
4624
+ A torn-down project is silent: nothing about the public map is its problem
4625
+ while it is off. */
4626
+ function publishRow(inst) {
4627
+ var v = inst && inst.publish;
4628
+ if (!v || v.publishable) return '';
4629
+ if (inst.status === 'torn_down' || inst.status === 'tearing_down') return '';
4630
+ return 'Not on the public map yet — it has ' + esc(v.missing_phrase || 'no description') +
4631
+ '. <a href="?view=manage&id=' + esc(String(inst.id)) + '" data-manage="' + esc(String(inst.id)) +
4632
+ '">Say what it is</a> and it goes on.';
4633
+ }
4634
+
4580
4635
  function renderCard(inst) {
4581
4636
  /* iid is for MARKUP interpolation only (esc() = HTML escape); the DOM
4582
4637
  lookup goes through liFor(), which CSS-escapes — the two escapes are
@@ -4598,6 +4653,8 @@ summary{min-height:24px;padding:3px 0;}
4598
4653
  (meta.length ? '<div class="projMeta">' + meta.map(function (m) { return '<span>' + m + '</span>'; }).join('') + '</div>' : '');
4599
4654
  var addr = addressRow(inst);
4600
4655
  if (addr) body += '<div class="addr">' + addr + '</div>';
4656
+ var pub = publishRow(inst);
4657
+ if (pub) body += '<div class="projMsg">' + pub + '</div>';
4601
4658
  if ((inst.status === 'error' || inst.error_note) && !inst.open_intent) {
4602
4659
  /* once a retry is queued the strip tells the story — the stale error
4603
4660
  paragraph would just contradict it (owner walk, 2026-08-12). Also
@@ -5111,6 +5168,11 @@ summary{min-height:24px;padding:3px 0;}
5111
5168
  the GitHub read: entry + tab return, never the in-flight tick */
5112
5169
  if (!keepRepo) loadVisibility(inst);
5113
5170
  if (!keepRepo) loadJoinability(inst);
5171
+ /* the publish gate (task 1002335) needs no second read — the verdict
5172
+ rode in on the instance itself. It sits behind the same keepRepo
5173
+ guard because its card holds a textarea the in-flight tick must
5174
+ never repaint out from under a typing owner. */
5175
+ if (!keepRepo) renderPublish(inst);
5114
5176
  })
5115
5177
  .catch(function (err) {
5116
5178
  var m = String(err && err.message);
@@ -5256,6 +5318,101 @@ summary{min-height:24px;padding:3px 0;}
5256
5318
  if (t.hasAttribute('data-repo-public-no')) { if (manageRepo) renderRepoVis(manageRepo); }
5257
5319
  });
5258
5320
 
5321
+ /* ── on the public map (task 1002335, BV1.R16): the publish gate ──────────
5322
+ The verdict is the SERVER's — instance.publish, derived from the ONE gate
5323
+ definition platform-identity owns and publishes over the kernel seam (ADR
5324
+ 0182 D4). This page renders it and offers the one identity field that can
5325
+ be missing; it never re-derives the rule, because a browser-side copy is
5326
+ how the card and the server start disagreeing about whether a project is
5327
+ published. No verdict at all (an instance whose platform runs no public
5328
+ map) hides the card rather than guessing.
5329
+
5330
+ Rendered on ENTRY and after a save — deliberately NOT from renderManage,
5331
+ which the fetch-free minute tick and the in-flight watch both call: this
5332
+ card holds a textarea, and a repaint mid-sentence would eat what the owner
5333
+ is typing. Nothing the server does in the background changes the answer
5334
+ anyway; only this form does. ── */
5335
+
5336
+ function pubMsg(html) { $('mPub').innerHTML = html; }
5337
+
5338
+ function renderPublish(inst) {
5339
+ var v = inst && inst.publish;
5340
+ if (!v) { $('mPublish').hidden = true; return; }
5341
+ $('mPublish').hidden = false;
5342
+ var on = !!v.publishable;
5343
+ /* the refusal NAMES what is absent, in the server's own words (ADR 0182
5344
+ D4) — a project told only "no" is indistinguishable from a broken one */
5345
+ var verdict = on
5346
+ ? '<b>' + esc(inst.slug) + ' is on the public map.</b> People can find it and ask to join.'
5347
+ : '<b>' + esc(inst.slug) + ' is not on the public map yet</b> — it has ' +
5348
+ esc(v.missing_phrase || 'no description') +
5349
+ '. Nobody can find it there or ask to join until it does.';
5350
+ pubMsg('<p class="pubVerdict">' + verdict + '</p>' +
5351
+ '<label class="wizField" for="mPubDesc"><span>What is this project?' +
5352
+ ' <small>' + (on ? 'Change it any time.' : 'A sentence or two is plenty.') + '</small></span>' +
5353
+ '<textarea id="mPubDesc" maxlength="600" rows="3" placeholder="A weather simulator you can play with in the browser."></textarea></label>' +
5354
+ '<div class="actions"><button type="button" class="btn small" data-pub-save>' +
5355
+ (on ? 'Save' : 'Put it on the map') + '</button></div>' +
5356
+ '<div class="projMsg" id="mPubMsg" role="status" aria-live="polite"></div>');
5357
+ /* the owner's own prose is set as a PROPERTY, never interpolated into the
5358
+ markup above — what someone typed about their project is not HTML */
5359
+ $('mPubDesc').value = inst.description || '';
5360
+ }
5361
+
5362
+ /* Saving sends the description and takes the server's verdict WHOLE. An empty
5363
+ box is a real answer, not a no-op: it clears the description and the project
5364
+ leaves the map — the honest consequence of deleting half the identity gate,
5365
+ which is why the receipt says so instead of just "Saved." */
5366
+ function doPubSave() {
5367
+ var ta = $('mPubDesc');
5368
+ if (!manageInst || !ta) return;
5369
+ var box = $('mPubMsg');
5370
+ var btn = document.querySelector('#mPub [data-pub-save]');
5371
+ if (btn) btn.disabled = true;
5372
+ box.className = 'projMsg'; box.textContent = 'Saving…';
5373
+ var asked = manageId; /* a stale answer must never repaint a newer project */
5374
+ fetch(API + '/provisioning/instances/' + encodeURIComponent(manageId) + '/detail', {
5375
+ method: 'PATCH', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
5376
+ body: JSON.stringify({ description: ta.value })
5377
+ }).then(function (res) {
5378
+ return jsonOrEmpty(res).then(function (data) {
5379
+ if (asked !== manageId) return;
5380
+ if (res.status === 401) { box.className = 'projMsg err'; box.innerHTML = signInAgain(); return; }
5381
+ if (!res.ok) {
5382
+ var e = apiError(data, res.status);
5383
+ box.className = 'projMsg err';
5384
+ box.textContent = e.message || 'The description could not be saved — try again in a moment.';
5385
+ return;
5386
+ }
5387
+ manageInst.description = data.description || null;
5388
+ manageInst.publish = data.publish || manageInst.publish;
5389
+ renderPublish(manageInst);
5390
+ var m = $('mPubMsg'); /* the re-render replaced the box this closure held */
5391
+ m.className = 'projMsg ok';
5392
+ m.textContent = (manageInst.publish && manageInst.publish.publishable)
5393
+ ? 'Saved — it’s on the public map.'
5394
+ : 'Saved. With no description it stays off the public map.';
5395
+ /* the list card carries the same verdict, and the owner may go back */
5396
+ if (byId[manageInst.id]) {
5397
+ byId[manageInst.id].description = manageInst.description;
5398
+ byId[manageInst.id].publish = manageInst.publish;
5399
+ }
5400
+ });
5401
+ }).catch(function () {
5402
+ if (asked !== manageId) return;
5403
+ var b = $('mPubMsg');
5404
+ if (b) { b.className = 'projMsg err'; b.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
5405
+ }).then(function () {
5406
+ var b = document.querySelector('#mPub [data-pub-save]');
5407
+ if (b) b.disabled = false;
5408
+ });
5409
+ }
5410
+
5411
+ $('mPub').addEventListener('click', function (ev) {
5412
+ var t = ev.target.closest && ev.target.closest('button');
5413
+ if (t && t.hasAttribute('data-pub-save')) doPubSave();
5414
+ });
5415
+
5259
5416
  /* ── who can see it (task 1003043): the platform-visibility toggle. Two
5260
5417
  reads, kept apart on purpose: the CHOICE comes from the platform's own
5261
5418
  settings row (what the owner asked for), and the line under it reads what
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.508",
3
+ "version": "1.19.510",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.508",
9
+ "version": "1.19.510",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.508",
3
+ "version": "1.19.510",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.508'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.510'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -1813,6 +1813,68 @@ test('one row grammar: the every-project row and the Your-projects row lay the S
1813
1813
  assert.match(COSMOS, /#sub-table-body\{overflow-x:auto;\}/, 'the wrap scrolls, never the page');
1814
1814
  });
1815
1815
 
1816
+ // ── the publish gate on the owner's own pages (task 1002335, BV1.R16) ────────
1817
+
1818
+ test('the manage page carries an "on the public map" card, and it ships as real markup', () => {
1819
+ const card = (HUB.match(/<div class="card mSec" id="mPublish"[\s\S]*?\n <\/div>/) || [''])[0];
1820
+ assert.ok(card, '#mPublish ships as markup, not built by script (C7)');
1821
+ assert.match(card, /hidden/, 'hidden until the server sends a verdict — a platform with no map says nothing');
1822
+ assert.match(card, /id="mPub"/, 'the volatile slot the verdict renders into');
1823
+ // the explanation is a SIBLING of the volatile slot, the #mRepoVisWhy shape, so
1824
+ // no re-render of the slot can drop it
1825
+ assert.match(card, /<details class="finePrint" id="mPubWhat">/);
1826
+ assert.match(card, /keep empty shells off the map/, 'it says why the bar exists, not just what it is');
1827
+ });
1828
+
1829
+ test('the verdict rendered is the SERVER’s — the browser never re-derives the gate', () => {
1830
+ const fn = (SCRIPT.match(/function renderPublish\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1831
+ assert.ok(fn, 'renderPublish exists');
1832
+ assert.match(fn, /inst && inst\.publish/, 'it reads the verdict off the instance');
1833
+ assert.match(fn, /v\.missing_phrase/, 'and names what is missing in the server’s own words (ADR 0182 D4)');
1834
+ // no second copy of the two-field rule anywhere in the page
1835
+ assert.doesNotMatch(SCRIPT, /publishable\s*=\s*[^;]*description/,
1836
+ 'the page must never compute publishability itself — that is how two surfaces disagree');
1837
+ // a missing verdict HIDES the card rather than guessing one
1838
+ assert.match(fn, /if \(!v\) \{ \$\('mPublish'\)\.hidden = true; return; \}/);
1839
+ });
1840
+
1841
+ test('the owner’s prose is set as a property, never interpolated into markup', () => {
1842
+ const fn = (SCRIPT.match(/function renderPublish\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1843
+ assert.match(fn, /\$\('mPubDesc'\)\.value = inst\.description \|\| '';/,
1844
+ 'what someone typed about their project is not HTML');
1845
+ assert.doesNotMatch(fn, /esc\(inst\.description/, 'and it never rides the innerHTML string at all');
1846
+ });
1847
+
1848
+ test('the publish card is rendered on entry only — the in-flight tick must not eat a half-typed sentence', () => {
1849
+ // renderManage() is called by the fetch-free minute tick AND the 5s in-flight
1850
+ // watch. A textarea repainted from either loses the caret and the text.
1851
+ const renderManage = (SCRIPT.match(/function renderManage\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1852
+ assert.doesNotMatch(renderManage, /renderPublish/,
1853
+ 'renderPublish must NOT hang off the repaint path');
1854
+ assert.match(SCRIPT, /if \(!keepRepo\) renderPublish\(inst\);/,
1855
+ 'it rides the entry + tab-return guard the other fetching sections use');
1856
+ });
1857
+
1858
+ test('saving the description takes the server’s verdict whole, and clearing it is a real answer', () => {
1859
+ const fn = (SCRIPT.match(/function doPubSave\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1860
+ assert.match(fn, /\/detail'/, 'it PATCHes the detail route R15 built as the way back');
1861
+ assert.match(fn, /method: 'PATCH'/);
1862
+ assert.match(fn, /manageInst\.publish = data\.publish \|\| manageInst\.publish/,
1863
+ 'the verdict comes back from the server, not from what was typed');
1864
+ assert.match(fn, /stays off the public map/,
1865
+ 'clearing the description un-publishes the project, and the receipt says so');
1866
+ });
1867
+
1868
+ test('a dark-matter project says so on its list card too, and only then', () => {
1869
+ const fn = (SCRIPT.match(/function publishRow\(inst\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1870
+ assert.ok(fn, 'publishRow exists');
1871
+ assert.match(fn, /if \(!v \|\| v\.publishable\) return '';/,
1872
+ 'being on the map is the expected state — announcing it on every card is noise');
1873
+ assert.match(fn, /torn_down/, 'a torn-down project has no business being told about the public map');
1874
+ assert.match(fn, /view=manage&id=/, 'the refusal points at the one page that fixes it');
1875
+ assert.match(SCRIPT, /var pub = publishRow\(inst\);/, 'and the card actually renders it');
1876
+ });
1877
+
1816
1878
  test('the sort left the column head for a control the keyboard and a screen reader can both read (task 1003356)', () => {
1817
1879
  // aria-sort is only valid on a columnheader; with no header row the order has
1818
1880
  // to be stated by the control itself and by a sentence in a live region
@@ -0,0 +1,268 @@
1
+ // tests/provisioning_starter_bundles.mjs — the curated starter bundles (BV1.R19,
2
+ // task 1002338; goal 1000046 criterion `proj-module-select`).
3
+ //
4
+ // Three things are pinned here, and the middle one is the reason this file exists.
5
+ //
6
+ // 1. The resolver — bundleForProjectType(type) for each declared type, and the
7
+ // core-only fallback for a type nobody declared. A creation form must never
8
+ // be handed a guess.
9
+ // 2. Bundle <-> catalog integrity — a bundle is a list of module KEYS, declared
10
+ // in a pure file that may not import the live catalog (the one-way rule, ADR
11
+ // 0083). This test IS the link: it reads every modules/*/module.json off disk
12
+ // and fails the build the moment a bundle names a module that does not exist,
13
+ // names an always-on one (a preselect that toggles nothing), or the declared
14
+ // always-on list drifts from the manifests' `default: true` set.
15
+ // 3. The read surface — GET /provisioning/starter-bundles is public and returns
16
+ // exactly what the resolver returns, so R20's picker and R17's rule engine
17
+ // cannot read a different answer than a script would.
18
+ //
19
+ // DB-free: the route is a pure read, so nothing here touches Postgres or the
20
+ // network beyond a loopback listener on port 0.
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/provisioning_starter_bundles.mjs
23
+
24
+ import { test, before, after } from 'node:test';
25
+ import { strict as assert } from 'node:assert';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import http from 'node:http';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createRequire } from 'node:module';
31
+
32
+ // ESM named imports off the CJS module on purpose: knip cannot trace a
33
+ // createRequire() import, so a test-only export would read as dead code and move
34
+ // the knip ratchet (see the repo's knip learning).
35
+ import {
36
+ ALWAYS_ON_CORE,
37
+ STARTER_BUNDLES,
38
+ HUB_ONLY_MODULES,
39
+ bundleForProjectType,
40
+ allStarterBundles,
41
+ } from '../modules/provisioning/starter-bundles.js';
42
+
43
+ const require = createRequire(import.meta.url);
44
+
45
+ // Stub the doorway in require.cache BEFORE the route file loads (the
46
+ // access_requests_invite pattern): the route destructures `pool` at require time,
47
+ // and a real pg pool would hold the event loop open long after the assertions
48
+ // pass. Nothing here needs a database — the route under test is a pure read.
49
+ const apiPath = require.resolve('../src/module-api.js');
50
+ const realApi = require(apiPath);
51
+ require.cache[apiPath].exports = {
52
+ ...realApi,
53
+ pool: { async query() { throw new Error('the starter-bundles route must not touch the database'); } },
54
+ };
55
+
56
+ const provisioning = require('../modules/provisioning/provisioning.js');
57
+
58
+ const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
59
+
60
+ // Every module key on disk, with the manifest's `default` — the live catalog as
61
+ // the loader discovers it (src/module-loader/loader.js scans exactly this).
62
+ function manifestsOnDisk() {
63
+ const out = new Map();
64
+ for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
65
+ if (!ent.isDirectory()) continue;
66
+ const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
67
+ if (!fs.existsSync(manifestPath)) continue;
68
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
69
+ out.set(manifest.key, manifest);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // ── the resolver ────────────────────────────────────────────────────────────
75
+
76
+ test('bundleForProjectType returns the always-on core plus the type\'s opt-in modules', () => {
77
+ // Arrange
78
+ const type = 'game';
79
+
80
+ // Act
81
+ const resolved = bundleForProjectType(type);
82
+
83
+ // Assert
84
+ assert.equal(resolved.type, 'game');
85
+ assert.deepEqual(resolved.core, [...ALWAYS_ON_CORE]);
86
+ assert.deepEqual(resolved.bundle, [...STARTER_BUNDLES.game.modules]);
87
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE, ...STARTER_BUNDLES.game.modules]);
88
+ assert.ok(resolved.label && resolved.summary, 'carries owner-facing copy for the picker to render');
89
+ });
90
+
91
+ test('every declared project type resolves to a bundle', () => {
92
+ // Arrange — the shipped vocabulary (task 1002772, owner decision 2026-08-13).
93
+ const declared = provisioning.PROJECT_TYPES;
94
+
95
+ // Act + Assert
96
+ for (const type of declared) {
97
+ const resolved = bundleForProjectType(type);
98
+ assert.equal(resolved.type, type, `${type} resolves to itself, not the fallback`);
99
+ }
100
+ assert.deepEqual(
101
+ Object.keys(STARTER_BUNDLES).sort(),
102
+ [...declared].sort(),
103
+ 'the bundle table covers the declared type vocabulary exactly — no missing type, no invented one',
104
+ );
105
+ });
106
+
107
+ test('an undeclared or unknown project type falls back to the core, and never guesses a type', () => {
108
+ // A project may have no type at all: the column is nullable because every row
109
+ // predates the field and must not be back-filled with a guess (migration
110
+ // provisioning_007). The fallback must be honest about that.
111
+ for (const type of [null, undefined, '', 'saas-tool', 'GAME', 42, {}]) {
112
+ // Act
113
+ const resolved = bundleForProjectType(type);
114
+
115
+ // Assert
116
+ assert.equal(resolved.type, null, `${JSON.stringify(type)} → no declared type`);
117
+ assert.deepEqual(resolved.bundle, [], 'nothing is preselected on a guess');
118
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE]);
119
+ }
120
+ });
121
+
122
+ test('"not-sure" is a real answer that resolves to the core only, distinct from an undeclared type', () => {
123
+ // Arrange + Act
124
+ const notSure = bundleForProjectType('not-sure');
125
+ const undeclared = bundleForProjectType(null);
126
+
127
+ // Assert — same modules, different provenance: one owner said "not sure", the
128
+ // other said nothing at all, and the picker should be able to tell them apart.
129
+ assert.deepEqual(notSure.modules, undeclared.modules);
130
+ assert.equal(notSure.type, 'not-sure');
131
+ assert.equal(undeclared.type, null);
132
+ assert.deepEqual(notSure.bundle, [], 'the escape hatch adds nothing — nothing depends on it');
133
+ });
134
+
135
+ test('a caller mutating a resolved bundle cannot corrupt the declaration', () => {
136
+ // Arrange
137
+ const first = bundleForProjectType('game');
138
+
139
+ // Act
140
+ first.modules.push('not-a-module');
141
+ first.bundle.length = 0;
142
+ first.core.length = 0;
143
+
144
+ // Assert
145
+ const second = bundleForProjectType('game');
146
+ assert.deepEqual(second.core, [...ALWAYS_ON_CORE]);
147
+ assert.deepEqual(second.bundle, [...STARTER_BUNDLES.game.modules]);
148
+ assert.ok(!second.modules.includes('not-a-module'));
149
+ });
150
+
151
+ test('allStarterBundles returns one resolved bundle per declared type', () => {
152
+ // Act
153
+ const all = allStarterBundles();
154
+
155
+ // Assert
156
+ assert.deepEqual(all.map((b) => b.type), Object.keys(STARTER_BUNDLES));
157
+ for (const row of all) assert.deepEqual(row, bundleForProjectType(row.type));
158
+ });
159
+
160
+ // ── bundle <-> catalog integrity ────────────────────────────────────────────
161
+
162
+ test('every module a bundle names exists in the module catalog', () => {
163
+ // Arrange
164
+ const onDisk = manifestsOnDisk();
165
+ assert.ok(onDisk.size > 0, 'read the manifests off disk');
166
+
167
+ // Act + Assert
168
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
169
+ for (const key of bundle.modules) {
170
+ assert.ok(onDisk.has(key), `bundle "${type}" names "${key}", which is not a module on disk`);
171
+ }
172
+ }
173
+ });
174
+
175
+ test('every always-on module is a real module whose manifest says default: true', () => {
176
+ // Arrange
177
+ const onDisk = manifestsOnDisk();
178
+
179
+ // Act + Assert
180
+ for (const key of ALWAYS_ON_CORE) {
181
+ assert.ok(onDisk.has(key), `ALWAYS_ON_CORE names "${key}", which is not a module on disk`);
182
+ assert.equal(onDisk.get(key).default, true, `"${key}" is listed as always-on but its manifest is not default: true`);
183
+ }
184
+ const defaultsOnDisk = [...onDisk.values()].filter((m) => m.default === true).map((m) => m.key).sort();
185
+ assert.deepEqual(
186
+ [...ALWAYS_ON_CORE].sort(),
187
+ defaultsOnDisk,
188
+ 'the declared always-on list has drifted from the manifests — a module was added, removed, or flipped its default',
189
+ );
190
+ });
191
+
192
+ test('a bundle may only add opt-in modules — never one that is already always-on', () => {
193
+ // A preselect that names an always-on module toggles nothing and reads to the
194
+ // owner as a choice they made; that is a lie the picker would render.
195
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
196
+ for (const key of bundle.modules) {
197
+ assert.ok(!ALWAYS_ON_CORE.includes(key), `bundle "${type}" names always-on module "${key}"`);
198
+ }
199
+ }
200
+ });
201
+
202
+ test('no bundle names a hub-only module', () => {
203
+ // platform-identity and provisioning are the control plane's own modules — the
204
+ // hub runs them, a project created THROUGH the hub never should.
205
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
206
+ for (const key of bundle.modules) {
207
+ assert.ok(!HUB_ONLY_MODULES.includes(key), `bundle "${type}" names hub-only module "${key}"`);
208
+ }
209
+ }
210
+ });
211
+
212
+ test('a bundle lists each module once', () => {
213
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
214
+ assert.equal(new Set(bundle.modules).size, bundle.modules.length, `bundle "${type}" repeats a module`);
215
+ }
216
+ });
217
+
218
+ // ── the read surface ────────────────────────────────────────────────────────
219
+
220
+ // Mount the real router. The route under test is a pure read, so it needs no
221
+ // session gate and no res.fail helper — which is itself the point of the
222
+ // assertion below.
223
+ const express = require('express');
224
+ const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
225
+ const app = express();
226
+ app.use('/api/bongos', buildProvisioningRouter());
227
+
228
+ let server;
229
+ let port;
230
+ before(async () => {
231
+ server = http.createServer(app);
232
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
233
+ port = server.address().port;
234
+ });
235
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
236
+
237
+ function get(pathname) {
238
+ return new Promise((resolve, reject) => {
239
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
240
+ let body = '';
241
+ res.on('data', (c) => { body += c; });
242
+ res.on('end', () => {
243
+ let parsed = null;
244
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
245
+ resolve({ status: res.statusCode, body: parsed, raw: body });
246
+ });
247
+ }).on('error', reject);
248
+ });
249
+ }
250
+
251
+ test('GET /provisioning/starter-bundles is public and serves the resolved bundles', async () => {
252
+ // Act — no session: `rank: public`, the same gate (none) the signed-out create
253
+ // wizard reads GET /provisioning/onboard-plan with.
254
+ const res = await get('/api/bongos/provisioning/starter-bundles');
255
+
256
+ // Assert
257
+ assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
258
+ assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
259
+ assert.deepEqual(res.body.bundles, allStarterBundles());
260
+ assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
261
+ });
262
+
263
+ test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
264
+ // The stubbed pool throws on any query, so a 200 here IS the assertion: an
265
+ // owner picking a type in the wizard must not cost a round trip to Postgres.
266
+ const res = await get('/api/bongos/provisioning/starter-bundles');
267
+ assert.equal(res.status, 200);
268
+ });