@bongos/core 1.19.441 → 1.19.442

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 (31) hide show
  1. package/.bongos-core.json +69 -29
  2. package/clients/bongos-client/index.d.ts +1 -2
  3. package/docs/api/openapi.json +3 -20
  4. package/docs/api-reference.md +1 -1
  5. package/docs/copy-inventory.md +111 -107
  6. package/docs/copy-registry.json +191 -144
  7. package/docs/design/projects-hub-direction-v2.md +23 -0
  8. package/docs/module-api-changelog.md +2 -0
  9. package/modules/platform-identity/routes/projects.js +2 -1
  10. package/modules/provisioning/provisioning.js +11 -2
  11. package/modules/provisioning/routes/provisioning.js +10 -5
  12. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  13. package/modules/ui-design/kit/serve.js +29 -4
  14. package/package-lock.json +2 -2
  15. package/package.json +1 -1
  16. package/public-landing/assets/cosmos.css +8 -0
  17. package/public-landing/assets/world/r4-card-chrome.jpg +0 -0
  18. package/public-landing/assets/world/r4-card-chrome.jpg.json +14 -0
  19. package/public-landing/assets/world/r4-card-deepfield.jpg +0 -0
  20. package/public-landing/assets/world/r4-card-deepfield.jpg.json +14 -0
  21. package/public-landing/assets/world/r4-card-dust.jpg +0 -0
  22. package/public-landing/assets/world/r4-card-dust.jpg.json +14 -0
  23. package/public-landing/assets/world/r4-card-nebula.jpg +0 -0
  24. package/public-landing/assets/world/r4-card-nebula.jpg.json +14 -0
  25. package/public-landing/projects-floor.states.json +28 -1
  26. package/public-landing/projects.html +101 -1
  27. package/public-landing/projects.probes.json +6 -0
  28. package/public-landing/projects.states.json +1 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/projects_hub.mjs +86 -3
  31. package/tests/provisioning_settings.mjs +49 -4
@@ -558,7 +558,7 @@ test('the ladder: each region hides on null and the other figure stands alone',
558
558
  assert.match(art, /classList\.toggle\('has-art', !!url\)/, 'art switches the whole layout');
559
559
  assert.match(SCRIPT, /\$\('cardArt'\)\.addEventListener\('error', function \(\) \{ cardArt\(''\); \}\);/,
560
560
  'a broken image demotes to the no-art layout, never a broken glyph');
561
- assert.match(SCRIPT, /cardArt\(p\.isSelf \? '' : safeHttpUrl\(p\.art_url\)\)/, 'the art URL is scheme-checked client-side too');
561
+ assert.match(SCRIPT, /safeHttpUrl\(p\.art_url\)\)\);/, 'the art URL is scheme-checked client-side too (behind the owner\'s plate since task 1003352)');
562
562
  });
563
563
 
564
564
  test('exactly one hall-URL derivation: the DOM for the hub, the feed for a project, safeHttpUrl for both, and only where the hall is elsewhere', () => {
@@ -1782,10 +1782,10 @@ test('the kit proves the picker: a state renders it on the owner\'s feed-carried
1782
1782
  assert.match(stub, /planet: PLANETS\.get\(String\(id\)\) \|\| NO_PLANET/, 'and answers it on the settings read and the receipt');
1783
1783
  });
1784
1784
 
1785
- test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads both keys on every project (task 1003347)', () => {
1785
+ test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads every platform-drawn key on every project (tasks 1003347, 1003352)', () => {
1786
1786
  const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1787
1787
  assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(projects\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
1788
- assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null \};/, 'the wire always carries both keys');
1788
+ assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null,\r?\n\s*card_backdrop: c\.card_backdrop \|\| null \};/, 'the wire always carries every key — null = nothing chosen');
1789
1789
  assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
1790
1790
  assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');
1791
1791
  const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
@@ -1827,6 +1827,89 @@ test('the sort left the column head for a control the keyboard and a screen read
1827
1827
  assert.match(COSMOS, /\.tblSort button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort is visible');
1828
1828
  });
1829
1829
 
1830
+ /* ── the card's backdrop (task 1003352): the owner's plate, beside the planet ── */
1831
+
1832
+ test('the kit stub refuses what the real route refuses, and its copied vocabulary cannot rot', async () => {
1833
+ // A kit module may not import core, so serve.js copies the closed lists. The
1834
+ // copy is what matters: it stayed permissive through a whole probe run while
1835
+ // the real PATCH would have 400'd on card_backdrop (task 1003352, grade round
1836
+ // 2), so the stub now validates — and this pins the copy to its source.
1837
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
1838
+ assert.match(stub, /function settingsRejection\(body\)/, 'the stub validates a settings body');
1839
+ assert.match(stub, /if \(bad\) return json\(res, 400, \{ error: \{ code: 'validation_failed'/, 'and answers the real code');
1840
+ const provisioning = createRequire(import.meta.url)('../modules/provisioning/provisioning.js');
1841
+ const block = (stub.match(/const STUB_VOCAB = \{([\s\S]*?)\n\};/) || [, ''])[1];
1842
+ assert.ok(block, 'STUB_VOCAB must keep its extractable shape');
1843
+ const real = { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB };
1844
+ for (const [key, values] of Object.entries(real)) {
1845
+ assert.match(block, new RegExp(`\\b${key}:`), `the stub must know ${key}`);
1846
+ if (key === 'planet_template') continue; // the template set is long: shape is checked, not membership
1847
+ for (const v of values) assert.match(block, new RegExp(`'${v}'`), `the stub's ${key} is missing ${v}`);
1848
+ }
1849
+ });
1850
+
1851
+ test('the card backdrop is the same KIND of key as the planet: a closed platform-drawn list on the same channel, pushing nothing', () => {
1852
+ const prov = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'), 'utf8');
1853
+ assert.match(prov, /const CARD_BACKDROPS = Object\.freeze\(\['nebula', 'dust', 'deepfield', 'chrome'\]\);/, 'a closed list, frozen');
1854
+ // riding PLANET_VOCAB is the whole point: planetChoice() loops it, the settings
1855
+ // route builds its validator and its settingKeys from it, and publicInstance
1856
+ // spreads it — so the key reaches the row, the read and the wire with no new branch
1857
+ assert.match(prov, /planet_template: PLANET_TEMPLATES, planet_accent: PLANET_ACCENTS, card_backdrop: CARD_BACKDROPS,/, 'it rides the platform-drawn vocab');
1858
+ // and the route's SCHEMA is that vocab, not a copy of it: a hand-written list
1859
+ // is what let card_backdrop reach settingKeys while strict validation still
1860
+ // 400'd every real save (grade round 2)
1861
+ const settingsRoute = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'), 'utf8');
1862
+ assert.match(settingsRoute, /const vocabSchema = \(vocab\) => Object\.fromEntries\(/, 'the schema is derived');
1863
+ assert.match(settingsRoute, /\.\.\.vocabSchema\(provisioning\.SETTINGS_VOCAB\),\r?\n\s*\.\.\.vocabSchema\(provisioning\.PLANET_VOCAB\),/, 'from both vocabularies');
1864
+ assert.doesNotMatch(settingsRoute, /planet_template: \{ type: 'string', enum: provisioning\.PLANET_VOCAB/, 'no hand-written key survives to drift');
1865
+ assert.match(prov, /if \(Object\.values\(choice\)\.some\(Boolean\)\) out\[r\.host\] = choice;/, 'a host that chose ONLY a backdrop still reaches the feed');
1866
+ const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1867
+ assert.match(route, /card_backdrop: c\.card_backdrop \|\| null/, 'the wire always carries the key — null = nothing chosen');
1868
+ });
1869
+
1870
+ test("the card paints the owner's plate before the project's own picture, and a key the platform does not draw paints nothing", () => {
1871
+ assert.match(SCRIPT, /cardArt\(p\.isSelf \? '' : \(backdropPlate\(p\.card_backdrop\) \|\| safeHttpUrl\(p\.art_url\)\)\);/,
1872
+ "the plate wins; the project's own art is the fallback, which is all the card read before");
1873
+ const fn = (SCRIPT.match(/function backdropPlate\(key\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
1874
+ assert.ok(fn, 'backdropPlate() must exist');
1875
+ assert.match(fn, /CARD_PLATES\[key\] \? '\/assets\/world\/' \+ CARD_PLATES\[key\] : ''/,
1876
+ 'an unknown key — a row written before a plate was retired — must never become a broken src');
1877
+ const plates = (SCRIPT.match(/var CARD_PLATES = \{([^}]*)\}/) || [, ''])[1];
1878
+ const offered = (SCRIPT.match(/var CARD_BACKDROPS = ([\s\S]*?);\r?\n/) || [, ''])[1];
1879
+ for (const k of ['nebula', 'dust', 'deepfield', 'chrome']) {
1880
+ assert.match(plates, new RegExp(`\\b${k}\\b`), `${k} has a plate`);
1881
+ assert.match(offered, new RegExp(`'${k}'`), `${k} is offered in the picker`);
1882
+ }
1883
+ });
1884
+
1885
+ test('every plate the picker offers ships as a real file with its provenance sidecar', () => {
1886
+ for (const k of ['nebula', 'dust', 'deepfield', 'chrome']) {
1887
+ const file = path.join(ROOT, 'public-landing', 'assets', 'world', `r4-card-${k}.jpg`);
1888
+ assert.ok(fs.existsSync(file), `r4-card-${k}.jpg must ship — the picker offers it`);
1889
+ const side = JSON.parse(fs.readFileSync(`${file}.json`, 'utf8'));
1890
+ assert.equal(side.task, 1003352, 'the sidecar names the task that made it');
1891
+ assert.ok(side.model && side.prompt, 'and the model and the prompt, so it can be remade');
1892
+ assert.equal(side.aspect_ratio, '5:2', "a card band, not a field ground — the reason this round existed");
1893
+ }
1894
+ });
1895
+
1896
+ test("its card is the planet section's twin: its own heading, the same channel, no restart", () => {
1897
+ // beside the planet rather than inside it, so each heading stays true: one is
1898
+ // how the project is drawn everywhere, the other is one surface's backdrop
1899
+ assert.match(HUB, /<div class="card mSec" id="mCard">\s*<h2>Its card<\/h2>/, 'its own section');
1900
+ assert.ok(HUB.indexOf('id="mCard"') > HUB.indexOf('id="mPlanetPick"'), 'and it stands after the planet, where the owner asked for it');
1901
+ assert.match(SCRIPT, /body: JSON\.stringify\(\{ card_backdrop: mCard \|\| null \}\)/, "the save is the settings PATCH; '' hands the pick back as null");
1902
+ assert.match(SCRIPT, /manageInst\.card_backdrop = \(data\.planet \|\| \{\}\)\.card_backdrop \|\| null;/,
1903
+ 'the reply is a receipt — the page repaints from the row, never from words in the reply');
1904
+ assert.match(SCRIPT, /renderPick\(inst\);\r?\n\s*renderCardPick\(inst\);/, 'both sections fill from the same manage read');
1905
+ const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
1906
+ assert.ok(states.states['in-manage-card'], 'the section has a rendered state');
1907
+ const probes = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.probes.json'), 'utf8'));
1908
+ const names = probes.probes.map((p) => p.name);
1909
+ assert.ok(names.some((n) => n.startsWith('manage flows: its card (task 1003352)')), 'the save probe');
1910
+ assert.ok(names.some((n) => n.includes("its card — handing it back")), 'the hand-back probe');
1911
+ });
1912
+
1830
1913
  /* ── the create wizard in the world (task 1003346; direction record §15, the pick: v1 the
1831
1914
  rail above, the bongo pair kept). Two sheets carry it: cosmos.css draws the FRAME by scope
1832
1915
  (#view-new), projects.html's own wizard block draws the wizard's structures. ── */
@@ -166,10 +166,13 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
166
166
  assert.equal(r.status, 200, r.raw);
167
167
  assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
- assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
169
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
171
171
  platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
172
172
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
173
+ // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
174
+ // closed list, no default, no push — so it rides the same vocab
175
+ card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
173
176
  });
174
177
  });
175
178
 
@@ -177,7 +180,7 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
177
180
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'purple' } } });
178
181
  const r = await get('/provisioning/instances/18/settings');
179
182
  assert.equal(r.status, 200, r.raw);
180
- assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null });
183
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
181
184
  assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
182
185
  });
183
186
 
@@ -268,7 +271,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
268
271
  reset();
269
272
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
270
273
  assert.equal(r.status, 200, r.raw);
271
- assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
274
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
272
275
  assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
273
276
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
274
277
  assert.equal(r.body.push.queued, false);
@@ -283,7 +286,7 @@ await ta('null hands the pick back to the platform (stored as null, read back as
283
286
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } } });
284
287
  const r = await patch('/provisioning/instances/18/settings', { planet_template: null, planet_accent: null });
285
288
  assert.equal(r.status, 200, r.raw);
286
- assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
289
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
287
290
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: null, planet_accent: null } }]);
288
291
  assert.equal(enqueueCalls, 0);
289
292
  });
@@ -316,6 +319,48 @@ await ta('a policy change and a planet change in one save: the push queues for t
316
319
  assert.equal(enqueueCalls, 1);
317
320
  });
318
321
 
322
+ await ta('the owner picks a card backdrop: the REAL router accepts it, echoes it, and still queues no push (task 1003352)', async () => {
323
+ // the regression this exists for: card_backdrop was added to PLANET_VOCAB (so
324
+ // settingKeys copied it) while the route's validator was a hand-written list
325
+ // that did not declare it — and strict mode 400s an undeclared field, so every
326
+ // real save from the manage page failed while the kit's probes stayed green
327
+ reset();
328
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: 'nebula' });
329
+ assert.equal(r.status, 200, r.raw);
330
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: 'nebula' });
331
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { card_backdrop: 'nebula' } }]);
332
+ assert.equal(r.body.push.queued, false, 'the card is the platform\'s to draw — nothing is pushed');
333
+ assert.equal(r.body.push.restart, false);
334
+ assert.equal(enqueueCalls, 0, 'no settings-apply intent for a backdrop change');
335
+ });
336
+
337
+ await ta('null hands the card backdrop back to the platform, like the planet', async () => {
338
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { card_backdrop: 'chrome' } } });
339
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: null });
340
+ assert.equal(r.status, 200, r.raw);
341
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
342
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { card_backdrop: null } }]);
343
+ });
344
+
345
+ await ta('an off-vocabulary card backdrop → 400 validation_failed (a closed list, not a free string)', async () => {
346
+ reset();
347
+ const r = await patch('/provisioning/instances/18/settings', { card_backdrop: 'sunset' });
348
+ assert.equal(r.status, 400, r.raw);
349
+ assert.equal(r.body.error.code, 'validation_failed');
350
+ assert.equal(updateCalls.length, 0);
351
+ });
352
+
353
+ await ta('the route\'s schema IS its vocabularies — every declared key is accepted, so one can never drift out', async () => {
354
+ // the guard against this whole class: a key added to SETTINGS_VOCAB or
355
+ // PLANET_VOCAB must be accepted by the validator without anyone remembering
356
+ const every = { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB };
357
+ for (const [key, values] of Object.entries(every)) {
358
+ reset();
359
+ const r = await patch('/provisioning/instances/18/settings', { [key]: values[0] });
360
+ assert.equal(r.status, 200, `${key}: ${r.raw}`);
361
+ }
362
+ });
363
+
319
364
  await ta('an off-vocabulary planet → 400 validation_failed (a colour is a token NAME, never a hex)', async () => {
320
365
  reset();
321
366
  for (const body of [{ planet_accent: '#fb9569' }, { planet_accent: 'purple' }, { planet_template: 'cube-gold-wash' }]) {