@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.
- package/.bongos-core.json +57 -32
- package/.claude/skills/new-project/SKILL.md +2 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0182-project-visibility-two-axes.md +1 -0
- package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +137 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +47 -6
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +210 -207
- package/docs/copy-registry.json +297 -269
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/platform-identity.js +30 -36
- package/modules/platform-identity/publish-gate.js +103 -0
- package/modules/platform-identity/routes/projects.js +31 -4
- package/modules/platform-identity/tests/platform-identity.mjs +7 -2
- package/modules/provisioning/routes/provisioning.js +70 -5
- package/modules/provisioning/starter-bundles.js +159 -0
- package/modules/public-landing/public/projects.html +157 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/projects_hub.mjs +62 -0
- package/tests/provisioning_starter_bundles.mjs +268 -0
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -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
|
+
});
|