@bongos/core 1.19.428 → 1.19.430

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 (42) hide show
  1. package/.bongos-core.json +69 -44
  2. package/clients/bongos-client/index.d.ts +4 -4
  3. package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +13 -2
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +137 -128
  8. package/docs/copy-registry.json +248 -166
  9. package/docs/design/mocks/hub/picker.html +421 -0
  10. package/docs/design/projects-hub-direction-v2.md +22 -0
  11. package/docs/file-map.md +2 -1
  12. package/docs/module-api-changelog.md +4 -0
  13. package/modules/hall-ui/public/profile-canonical.js +137 -0
  14. package/modules/hall-ui/public/profile-connections.js +20 -9
  15. package/modules/hall-ui/public/profile.html +9 -0
  16. package/modules/hall-ui/public/profile.js +5 -0
  17. package/modules/platform-identity/canonical-profile.js +143 -0
  18. package/modules/platform-identity/routes/projects.js +24 -0
  19. package/modules/platform-identity/routes/sso.js +7 -0
  20. package/modules/provisioning/module.json +1 -1
  21. package/modules/provisioning/provisioning.js +63 -2
  22. package/modules/provisioning/routes/provisioning.js +28 -6
  23. package/modules/provisioning/tests/provisioning.mjs +34 -0
  24. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -1
  25. package/modules/ui-design/kit/fixtures/featured.json +1 -1
  26. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  27. package/modules/ui-design/kit/serve.js +28 -4
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/public-landing/assets/cosmos.css +37 -0
  31. package/public-landing/projects.html +164 -5
  32. package/public-landing/projects.probes.json +12 -0
  33. package/public-landing/projects.states.json +3 -2
  34. package/src/bongos/routes/builders.js +24 -2
  35. package/src/bongos/serve-internal.js +28 -2
  36. package/src/module-api.js +1 -1
  37. package/tests/canonical_profile_url.mjs +628 -0
  38. package/tests/profile_connections_ui.mjs +38 -0
  39. package/tests/profile_route.mjs +30 -5
  40. package/tests/projects_hub.mjs +122 -7
  41. package/tests/provisioning_settings.mjs +81 -2
  42. package/tests/provisioning_settings_env.mjs +6 -0
@@ -544,6 +544,44 @@ test('a 2nd-degree connection names the mutuals it was computed from', async ()
544
544
  assert.match(page.html(), /data-handle="bo"/, 'the write must address the canonical handle, never the login the page happened to hold');
545
545
  });
546
546
 
547
+ // The gap ADR 0212 stated honestly and left to task 1002804: the login hop
548
+ // resolves against the current handle or ONE previous-handle slot, so a builder
549
+ // who renamed twice matched neither and got no section at all. Now that
550
+ // GET /builders/:id/profile carries the canonical `handle` (ADR 0214 D3), the
551
+ // page addresses by that and the login is only the fallback.
552
+ test('the canonical handle from the profile response is what gets addressed, not the login', async () => {
553
+ reset();
554
+ const page = boot();
555
+ // A twice-renamed builder: 'bo' is current, 'bo-was' is the single previous
556
+ // slot, and the login the page holds ('bo-ancient') matches NEITHER.
557
+ await page.render({ builder: { id: 12, github_login: 'bo-ancient' }, handle: 'bo' }, 99);
558
+ assert.equal(page.$('#conn-scroll').hidden, false,
559
+ 'a twice-renamed builder used to get no connections section at all — that is the hole the widened response closes');
560
+ assert.match(page.html(), />2nd</);
561
+ assert.ok(asked.some((p) => p.includes('/connections/degree/bo')),
562
+ 'the canonical handle must be what is asked for');
563
+ assert.ok(!asked.some((p) => p.includes('bo-ancient')),
564
+ 'the stale login must not be sent once the response names the builder canonically');
565
+ });
566
+
567
+ test('a profile response with no handle still falls back to the login hop', async () => {
568
+ reset();
569
+ const page = boot();
570
+ // An instance whose server predates the widening sends no `handle` key at all.
571
+ await page.render(profileOf('bo-was', 12), 99);
572
+ assert.match(page.html(), />2nd</);
573
+ assert.ok(asked.some((p) => p.includes('/connections/degree/bo-was')),
574
+ 'without a handle the page must still hand over the login it holds');
575
+ });
576
+
577
+ test('an empty-string handle is not treated as an address', async () => {
578
+ reset();
579
+ const page = boot();
580
+ await page.render({ builder: { id: 12, github_login: 'bo-was' }, handle: '' }, 99);
581
+ assert.ok(asked.some((p) => p.includes('/connections/degree/bo-was')),
582
+ 'a falsy handle must fall through to the login, never be sent as an empty path segment');
583
+ });
584
+
547
585
  test('a 3rd-degree connection renders "3rd"', async () => {
548
586
  reset();
549
587
  const page = boot();
@@ -470,11 +470,36 @@ await t('R06.1 the record is viewer-INDEPENDENT — another account sees the sam
470
470
  await t('R06.1 the profile read takes NO auth middleware — the portfolio is shareable', () => {
471
471
  // A future PR walling this behind requireBuilder would break "another account
472
472
  // can see it" while every assertion above still passed, so pin the shape.
473
- const line = sourceOf('src/bongos/routes/builders.js').split('\n')
474
- .find((l) => l.includes("router.get('/builders/:id/profile'"));
475
- assert.ok(line, 'the profile route registration is findable');
476
- assert.match(line, /router\.get\('\/builders\/:id\/profile', async \(req, res\) => \{/,
477
- 'nothing may sit between the path and the handler — that is what makes the record public');
473
+ //
474
+ // Asserted against the REAL ROUTER STACK rather than the registration line
475
+ // (task 1002804). The old line-regex demanded the handler be the only thing on
476
+ // the route, which caught an inline `requireBuilder` — but missed one mounted
477
+ // by `router.use`, and could not tell an auth gate from a rate limiter. This
478
+ // form catches both mounting styles and names what it forbids: AUTH. Throttling
479
+ // is not authentication — /public/* is rate-limited and public — so the shared
480
+ // public read limiter is allowed BY IDENTITY (a private copy would be a second
481
+ // budget, ADR 0209), and nothing else is.
482
+ const auth = require('../src/bongos/auth.js');
483
+ const { publicReadRateLimitMiddleware } = require('../src/bongos/middleware/rate-limit.js');
484
+ const router = buildBuildersRouter();
485
+ const layer = router.stack.find(
486
+ (l) => l.route && l.route.path === '/builders/:id/profile' && l.route.methods.get,
487
+ );
488
+ assert.ok(layer, 'the profile route registration is findable');
489
+
490
+ const bare = router.stack.filter((l) => !l.route).map((l) => l.handle);
491
+ assert.deepEqual(bare, [], 'a router-level middleware would gate EVERY route here, this one included');
492
+
493
+ const handlers = layer.route.stack.map((s) => s.handle);
494
+ const authGates = new Set(Object.values(auth).filter((v) => typeof v === 'function'));
495
+ for (const h of handlers) {
496
+ assert.ok(!authGates.has(h),
497
+ 'an auth gate sits on the profile read — that is what would stop another account seeing the record');
498
+ }
499
+ const ALLOWED = new Set([publicReadRateLimitMiddleware]);
500
+ const extra = handlers.slice(0, -1).filter((h) => !ALLOWED.has(h));
501
+ assert.deepEqual(extra, [],
502
+ 'only the shared public read limiter may sit between the path and the handler — anything else must justify itself here first');
478
503
  });
479
504
 
480
505
  await t('R06.4 NEGATIVE CONTROL: the leak guard goes red when a private field is planted', async () => {
@@ -187,8 +187,10 @@ test('the mine row draws the project\'s planet — the catalog\'s template when
187
187
  assert.doesNotMatch(orb, /class="orb/, 'the sphere is gone');
188
188
  const planet = (SCRIPT.match(/function planetOf\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
189
189
  assert.match(planet, /hostOf\(catalog\[i\]\.origin\) === h/, 'matched by host, the same join the map makes');
190
- assert.match(planet, /tpl: templateOf\(catalog\[i\]\), acc: accentOf\(catalog\[i\]\)/, 'a catalog match takes the catalog\'s template + accent');
191
- assert.match(planet, /tpl: slugTemplate\(inst\.slug\), acc: ORB_ACCENTS\[slugAccent\(inst\.slug\)\]/, 'owned-only walks by slug');
190
+ assert.match(planet, /var own = \{ tpl: knownTemplate\(inst\.planet_template\), acc: accentVar\(inst\.planet_accent\) \};/, 'the owner\'s own choice is read off the row first (task 1003347)');
191
+ assert.match(planet, /tpl: own\.tpl \|\| templateOf\(c\), acc: own\.acc \|\| accentOf\(c\)/, 'a catalog match takes the catalog\'s template + accent under the owner\'s choice');
192
+ assert.match(planet, /tpl: own\.tpl \|\| slugTemplate\(inst\.slug\), acc: own\.acc \|\| ORB_ACCENTS\[slugAccent\(inst\.slug\)\]/, 'owned-only walks by slug under the owner\'s choice');
193
+ assert.match(planet, /chosen: !!\(own\.tpl \|\| own\.acc\)/, 'the row says whose pick it is');
192
194
  assert.match(SCRIPT, /assignAccents\(\); applyMapFilter\(\); repaintPlanets\(\);/, 'a catalog that lands late repaints the rows');
193
195
  assert.doesNotMatch(COSMOS, /\n\.orb\{/, 'cosmos no longer carries the sphere');
194
196
  });
@@ -1531,8 +1533,9 @@ test('the planet templates: shape × material × style; the walk changes all thr
1531
1533
  test('the walk assigns the template by canonical position beside the accent; a curated template wins; the platform is the pair in chrome', () => {
1532
1534
  assert.match(SCRIPT, /function assignAccents[\s\S]*?p\._acc = ORB_ACCENTS\[i % ORB_ACCENTS\.length\];\s*p\._tpl = ORB_WALK\[i % ORB_WALK\.length\];/,
1533
1535
  'one walk, two assignments');
1534
- assert.match(SCRIPT, /function templateOf\(p\) \{\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\| ORB_WALK\[0\]\);/,
1535
- 'a curated name wins; an unknown name is not a choice');
1536
+ assert.match(SCRIPT, /function templateOf\(p\) \{\s*if \(knownTemplate\(p\.planet_template\)\) return p\.planet_template;\s*return ORB_TEMPLATES\.indexOf\(p\.template\) !== -1 \? p\.template : \(p\._tpl \|\| ORB_WALK\[0\]\);/,
1537
+ 'the owner\'s choice wins, then a curated name; an unknown name is not a choice (task 1003347)');
1538
+ assert.match(SCRIPT, /function accentOf\(p\) \{ return accentVar\(p\.planet_accent\) \|\| p\._acc \|\| ORB_ACCENTS\[0\]; \}/, 'the owner\'s colour by NAME, then the walk');
1536
1539
  assert.match(SCRIPT, /_acc: ORB_ACCENTS\[slugAccent\(inst\.slug\)\], _tpl: slugTemplate\(inst\.slug\)/, 'an owned-only entry walks by its slug');
1537
1540
  assert.match(SCRIPT, /function slugTemplate\(slug\) \{ return ORB_WALK\[slugHash\(slug\) % ORB_WALK\.length\]; \}/, 'by the walk, not the whole set');
1538
1541
  assert.match(SCRIPT, /var tpl = templateOf\(p\), tplParts = tpl\.split\('-'\);\s*a\.setAttribute\('data-template', tpl\);\s*a\.setAttribute\('data-shape', tplParts\[0\]\);\s*a\.setAttribute\('data-material', tplParts\.length === 3 \? tplParts\[1\] : 'chrome'\);\s*a\.setAttribute\('data-style', tplParts\[tplParts\.length - 1\]\);/,
@@ -1647,13 +1650,125 @@ test('the kit proves the field path: the fixture carries a curated template and
1647
1650
  const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'featured.json'), 'utf8'));
1648
1651
  const otb = fixture.projects.find((p) => p.origin === 'https://amazonprimea.com');
1649
1652
  assert.equal(otb.template, 'bead-wash');
1650
- assert.equal(fixture.projects.find((p) => p.origin === 'https://demo.cloudbongos.com').template, 'pair-gold-wash', 'a material template rides the wire (task 1003337)');
1651
- assert.ok(fixture.projects.every((p) => 'template' in p), 'the wire never drops the key');
1653
+ const demo = fixture.projects.find((p) => p.origin === 'https://demo.cloudbongos.com');
1654
+ assert.equal(demo.template, 'pair-gold-wash', 'a material template rides the wire (task 1003337)');
1655
+ assert.equal(demo.planet_template, 'pair-obsidian-tint', 'the owner\'s choice rides the same wire (task 1003347)');
1656
+ assert.equal(demo.planet_accent, 'sea', 'a token NAME, never a colour');
1657
+ assert.ok(fixture.projects.every((p) => 'template' in p && 'planet_template' in p && 'planet_accent' in p), 'the wire never drops a key');
1652
1658
  const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
1653
1659
  const attrs = states.states['in-map'].expect.attr.map((a) => a.join(' '));
1654
1660
  assert.ok(attrs.includes('#selfOrb data-template pair-chrome'));
1655
1661
  assert.ok(attrs.some((a) => a.includes('amazonprimea.com') && a.endsWith('data-template bead-wash')), 'the curated template reaches the orb');
1656
- assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material gold')), 'the material reaches the orb as its own attribute');
1662
+ assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-material obsidian')), 'the material reaches the orb as its own attribute');
1663
+ assert.ok(attrs.some((a) => a.includes('demo.cloudbongos.com') && a.endsWith('data-template pair-obsidian-tint')), 'the owner\'s choice outranks the curated template on the sky (task 1003347)');
1664
+ });
1665
+
1666
+ /* ── the planet picker (task 1003347; direction record §16, v1 the bands): the owner's own
1667
+ choice, read first everywhere a planet is drawn; the "its planet" section on the manage
1668
+ page; a settings save that restarts nothing ── */
1669
+ test('the picker: the owner\'s choice is one of the sky\'s thirty-six names and one of the five accents BY NAME — the same lists the server validates (task 1003347)', () => {
1670
+ const req = createRequire(import.meta.url);
1671
+ const prov = req(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
1672
+ assert.match(SCRIPT, /var ORB_ACCENT_NAMES = \['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'\];/, 'the five by name, in the palette\'s order');
1673
+ assert.deepEqual([...prov.PLANET_ACCENTS], ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'], 'the server\'s allow-list is the same five');
1674
+ assert.match(SCRIPT, /function accentVar\(name\) \{ var i = ORB_ACCENT_NAMES\.indexOf\(name\); return i === -1 \? '' : ORB_ACCENTS\[i\]; \}/, 'a name maps to its var(); an unknown name is nothing');
1675
+ // the client mirror of PROJECT_TEMPLATES (pinned above) and the server's PLANET mirror agree
1676
+ const pi = req(path.join(ROOT, 'modules', 'platform-identity', 'platform-identity.js'));
1677
+ assert.deepEqual([...prov.PLANET_TEMPLATES], [...pi.PROJECT_TEMPLATES], 'provisioning validates the very names the catalog and the sky know');
1678
+ assert.match(SCRIPT, /function pickTemplate\(s\) \{\s*if \(s\.material === 'chrome'\) return s\.shape \+ '-' \+ \(s\.style === 'plain' \? 'chrome' : s\.style\);\s*return s\.shape \+ '-' \+ s\.material \+ '-' \+ s\.style;/, 'shape × material × style composes a stored name; the chrome material\'s plain is spelled chrome');
1679
+ assert.match(SCRIPT, /function pickFrom\(pl\) \{[\s\S]*?style: style === 'chrome' \? 'plain' : style,\s*accent: ORB_ACCENT_NAMES\[ORB_ACCENTS\.indexOf\(pl\.acc\)\] \|\| ORB_ACCENT_NAMES\[0\] \};/, 'and a drawn planet decomposes back into the four fields');
1680
+ });
1681
+
1682
+ test('the picker is live markup on the manage page: the section, its body slot and a sibling fold (the .mSec shape), and the ledger row says whose pick it is', () => {
1683
+ assert.match(HUB, /<div class="card mSec" id="mPlanetPick">\s*<h2>Its planet<\/h2>\s*<div id="mPick">/, 'the section with its volatile slot');
1684
+ assert.match(HUB, /<details class="finePrint" id="mPickWhat">/, 'the fold is a SIBLING of the slot');
1685
+ const sec = (HUB.match(/<div class="card mSec" id="mPlanetPick">([\s\S]*?)<\/details>/) || [, ''])[1];
1686
+ assert.match(sec, /<b>shape<\/b>[\s\S]*<b>material<\/b>[\s\S]*<b>style<\/b>[\s\S]*<b>colour<\/b>/, 'the fold names the four choices');
1687
+ assert.match(sec, /no restart, nothing to apply/, 'and says the planet is the platform\'s to draw');
1688
+ assert.match(HUB, /id="mJoinability"[\s\S]*id="mPlanetPick"[\s\S]*id="mDelete"/, 'between how people join and the ender');
1689
+ const pm = (SCRIPT.match(/function paintManagePlanet\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
1690
+ assert.match(pm, /pl\.chosen \? '<small>your choice\.<\/small>' : pl\.curated \? '' : '<small>chosen for it by the platform\.<\/small>'/, 'your choice · (curated, no clause) · chosen for it by the platform');
1691
+ const rm = (SCRIPT.match(/function renderManage\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
1692
+ assert.match(rm, /renderPick\(inst\);/, 'renderManage paints the picker, fetch-free');
1693
+ });
1694
+
1695
+ test('the picker\'s script: the preview planet and four radiogroups built from the sky\'s own names; a pick in progress survives the tick and a late catalog; the save is the settings PATCH and restarts nothing', () => {
1696
+ const rp = (SCRIPT.match(/function renderPick\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
1697
+ assert.match(rp, /var pl = planetOf\(inst\);/, 'one resolution — the head, the row and the preview can never disagree');
1698
+ assert.match(rp, /<span class="worldOrb"' \+ planetAttrs\(preview\) \+ ' style="--acc:' \+ preview\.acc \+ ';--d:132px"/, 'the preview is the sky\'s object with the template attributes');
1699
+ assert.match(rp, /pickBand\('Shape', 'mPickShape', PICK_SHAPES, s\.shape\)[\s\S]*pickBand\('Material', 'mPickMaterial', PICK_MATERIALS, s\.material\)[\s\S]*pickBand\('Style', 'mPickStyle', PICK_STYLES, s\.style\)/, 'three bands of cells');
1700
+ assert.match(rp, /<div class="pickSwatches" role="radiogroup" aria-label="Colour">/, 'the colour as a radiogroup of swatches');
1701
+ assert.match(rp, /style="--sw:' \+ accentVar\(a\[0\]\) \+ '"/, 'a swatch is its token, never a literal');
1702
+ assert.match(rp, /pl\.chosen \? '<button type="button" class="btn small ghost" data-pick-reset>Let the platform choose again<\/button>' : ''/, 'the way back to the platform\'s pick, only once a choice is stored');
1703
+ assert.match(rp, /else if \(\$\('mPick'\)\.querySelector\('\.pick'\)\) return;/, 'the tick does not rebuild a pick in progress');
1704
+ assert.match(SCRIPT, /if \(manageInst && !mPickTouched && JSON\.stringify\(pickFrom\(planetOf\(manageInst\)\)\) !== mPickSeed\) \{ mPick = null; renderPick\(manageInst\); \}/, 'a late catalog reseeds only an untouched pick whose resolution changed');
1705
+ const save = (SCRIPT.match(/function doPickSave\(reset\) \{([\s\S]*?)\n \}/) || [, ''])[1];
1706
+ assert.match(save, /var body = reset \? \{ planet_template: null, planet_accent: null \} : \{ planet_template: pickTemplate\(mPick\), planet_accent: mPick\.accent \};/, 'null hands the pick back; a choice sends the name and the accent by name');
1707
+ assert.match(save, /\/settings', \{\s*method: 'PATCH'/, 'the settings PATCH the two sections above use');
1708
+ assert.match(save, /manageInst\.planet_template = planet\.planet_template \|\| null;\s*manageInst\.planet_accent = planet\.planet_accent \|\| null;/, 'the row now holds the choice');
1709
+ assert.match(save, /repaintPlanets\(\);/, 'the head, the ledger row and the list repaint from the row');
1710
+ assert.match(save, /loadCatalog\(\);/, 'and the sky re-reads the feed that carries it');
1711
+ assert.doesNotMatch(save, /loadManage\(/, 'no re-read for an open intent: a planet change queues none');
1712
+ assert.match(save, /\(data\.push && data\.push\.message\) \|\| 'Saved\.'/, 'the receipt is shown verbatim');
1713
+ });
1714
+
1715
+ test('the picker wears the world by scope: the preview left and four bands right on hairlines, cells with the dot, swatches as their tokens; under 900 the cells are the list\'s pills; no box shadow, no literal colour', () => {
1716
+ const block = (COSMOS.match(/\/\* ── its planet \(task 1003347([\s\S]*?)\/\* this block stays HERE/) || [, ''])[1];
1717
+ assert.ok(block, 'the block sits in the manage page\'s scope');
1718
+ assert.match(block, /#view-manage \.pick\{display:grid;grid-template-columns:168px 1fr;gap:0 34px;align-items:start;\}/, 'the preview beside the bands');
1719
+ assert.match(block, /#view-manage \.pickBand\{display:grid;grid-template-columns:88px 1fr;[^}]*border-top:1px solid var\(--rule\);\}/, 'each band on a hairline, its label in a column');
1720
+ assert.match(block, /#view-manage \.pickCells\[role="radiogroup"\]\{display:grid;grid-template-columns:none;grid-auto-flow:column;grid-auto-columns:1fr;border:0;\}/, 'the cells outrank the generic radiogroup rule');
1721
+ assert.match(block, /#view-manage \.pickCell::before\{content:"";width:7px;height:7px;border-radius:50%;/, 'the dot');
1722
+ assert.match(block, /#view-manage \.pickCell\.on::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid color-mix\(in srgb,var\(--accent\) 22%,transparent\);\}/, 'the chosen cell\'s dot on the accent with its halo');
1723
+ assert.match(block, /#view-manage \.pickSw\{[^}]*background:var\(--sw\);/, 'a swatch paints its token');
1724
+ assert.match(block, /#view-manage \.pickSw\.on\{outline:1px solid var\(--ink\);outline-offset:3px;\}/, 'the chosen swatch ringed by a hairline of the ink');
1725
+ assert.doesNotMatch(block, /box-shadow/, 'hairlines only');
1726
+ assert.doesNotMatch(block, /#[0-9a-fA-F]{3,8}\b/, 'no literal colour (the Fifteen Rule)');
1727
+ const fold = (COSMOS.match(/@media \(max-width:900px\)\{[\s\S]*?#view-manage \.pick\{grid-template-columns:1fr;gap:22px 0;\}([\s\S]*?)\n\}/) || [, ''])[1];
1728
+ assert.match(fold, /#view-manage \.pickCells\[role="radiogroup"\]\{display:flex;flex-wrap:wrap;gap:8px;padding:0 0 14px;\}/, 'the cells flow as pills');
1729
+ assert.match(fold, /#view-manage \.pickCell\.on\{background:var\(--ink\);color:var\(--bg\);border-color:var\(--ink\);\}/, 'the chosen one inverted to ink, the filter pills\' grammar');
1730
+ assert.match(fold, /#view-manage \.pickCell::before\{display:none;\}/, 'no dot on a pill');
1731
+ });
1732
+
1733
+ test('the kit proves the picker: a state renders it on the owner\'s feed-carried choice, three probes save / hand back / fold, and both fixtures carry the choice (task 1003347)', () => {
1734
+ const states = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.states.json'), 'utf8'));
1735
+ const st = states.states['in-manage-planet'];
1736
+ assert.ok(st && st.auth && st.url === '/projects?view=manage&id=101', 'the signed-in owner on Demo');
1737
+ assert.ok(st.expect.visible.includes('#mPlanetPick') && st.expect.visible.includes('#mPick [data-pick-reset]'));
1738
+ assert.deepEqual(st.expect.count.find((c) => c[0] === '#mPick .pickCell'), ['#mPick .pickCell', 10], 'three + four + three cells');
1739
+ assert.deepEqual(st.expect.count.find((c) => c[0] === '#mPick .pickSw'), ['#mPick .pickSw', 5]);
1740
+ assert.ok(st.expect.attr.some((a) => a[0] === '#mOrb .worldOrb' && a[2] === 'pair-obsidian-tint'), 'the head wears the owner\'s choice over the curated template');
1741
+ assert.ok(st.expect.text.some((t) => t[0] === '#mPlanet .v small' && t[1] === 'your choice.'));
1742
+ const mine = states.states['in-mine'].expect.count.find((c) => c[0] === '#projectsList .proj .worldOrb[data-template="blob-frost-tint"]');
1743
+ assert.deepEqual(mine, ['#projectsList .proj .worldOrb[data-template="blob-frost-tint"]', 1], 'an owned-only row draws the choice off its own row');
1744
+ const inst = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'provisioning-instances.json'), 'utf8'));
1745
+ const harbor = inst.instances.find((i) => i.slug === 'harbor');
1746
+ assert.equal(harbor.planet_template, 'blob-frost-tint');
1747
+ assert.equal(harbor.planet_accent, 'sun-lit');
1748
+ const probes = JSON.parse(fs.readFileSync(path.join(ROOT, 'public-landing', 'projects.probes.json'), 'utf8'));
1749
+ const names = probes.probes.map((p) => p.name);
1750
+ assert.ok(names.some((n) => n.startsWith('manage flows: its planet (task 1003347)')), 'the save probe');
1751
+ assert.ok(names.some((n) => n.includes('handed back to the platform')), 'the reset probe');
1752
+ assert.ok(names.some((n) => n.includes('its planet at 390')), 'the fold probe');
1753
+ const saveProbe = probes.probes.find((p) => p.name.startsWith('manage flows: its planet (task 1003347)'));
1754
+ assert.ok(saveProbe.expect.text.some((t) => t[1] === 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.'), 'the route\'s no-restart receipt, verbatim');
1755
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
1756
+ assert.match(stub, /const PLANETS = new Map\(\);/, 'the stub remembers a saved planet for the run');
1757
+ assert.match(stub, /planet: PLANETS\.get\(String\(id\)\) \|\| NO_PLANET/, 'and answers it on the settings read and the receipt');
1758
+ });
1759
+
1760
+ 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)', () => {
1761
+ const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1762
+ assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(projects\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
1763
+ assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null \};/, 'the wire always carries both keys');
1764
+ assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
1765
+ assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');
1766
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'module.json'), 'utf8'));
1767
+ assert.ok(manifest.provides.includes('provisioning.projectPlanets'), 'the port is declared');
1768
+ const pi = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'module.json'), 'utf8'));
1769
+ assert.ok(!(pi.consumes || []).includes('provisioning.projectPlanets'), 'not a required capability — the hub must boot without provisioning');
1770
+ const prov = fs.readFileSync(path.join(ROOT, 'modules', 'provisioning', 'routes', 'provisioning.js'), 'utf8');
1771
+ assert.match(prov, /api\.registerProvider\('provisioning\.projectPlanets', \(hosts\) => provisioning\.planetChoicesForHosts\(pool, hosts\)\);/, 'registered beside instanceState');
1657
1772
  });
1658
1773
 
1659
1774
  /* ── the table's columns on FIXED tracks (task 1003348; the owner, 2026-08-29: "the
@@ -44,7 +44,8 @@ provisioning.recordEvent = async (_db, e) => { events.push(e); };
44
44
  provisioning.getOpenIntent = async () => null;
45
45
  // The live push (task 1003140) — stubbed so the reachable path is deterministic here;
46
46
  // its own outcomes are covered in tests/provisioning_settings_apply.mjs.
47
- provisioning.enqueueSettingsApply = async () => ({ intent: { id: 9, action: 'settings-apply', state: 'pending' }, created: true });
47
+ let enqueueCalls = 0; // the live push — a planet change must never queue one (task 1003347)
48
+ provisioning.enqueueSettingsApply = async () => { enqueueCalls++; return { intent: { id: 9, action: 'settings-apply', state: 'pending' }, created: true }; };
48
49
  let manifest = null; // the latest manifest attempt the read surfaces (task 1003235)
49
50
  provisioning.getLatestOAuthManifestForInstance = async () => manifest;
50
51
 
@@ -100,6 +101,7 @@ function reset({ inst, as } = {}) {
100
101
  builder = as || { id: 7, github_login: 'owner', rank: 'thetes' };
101
102
  updateCalls = [];
102
103
  events = [];
104
+ enqueueCalls = 0;
103
105
  }
104
106
 
105
107
  console.log('GET /provisioning/instances/:id (the manage page\'s read — task 1003041):');
@@ -163,7 +165,20 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
163
165
  const r = await get('/provisioning/instances/18/settings');
164
166
  assert.equal(r.status, 200, r.raw);
165
167
  assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' });
166
- assert.deepEqual(r.body.options, { platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'] });
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 });
170
+ assert.deepEqual(r.body.options, {
171
+ platform_visibility: ['public', 'gated'], joinability: ['open', 'apply', 'invite_only'],
172
+ planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
173
+ });
174
+ });
175
+
176
+ await ta('a stored planet reads back; a name that left the vocabulary reads null (task 1003347)', async () => {
177
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'purple' } } });
178
+ const r = await get('/provisioning/instances/18/settings');
179
+ assert.equal(r.status, 200, r.raw);
180
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null });
181
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply' }, 'the planet is not a setting: the policy read is untouched');
167
182
  });
168
183
 
169
184
  await ta('stored keys overlay the defaults', async () => {
@@ -247,6 +262,70 @@ await ta('an unknown key → 400 (strict: a typo fails loudly, never silently dr
247
262
  assert.equal(updateCalls.length, 0);
248
263
  });
249
264
 
265
+ console.log('\nPATCH …/settings — the planet (task 1003347):');
266
+
267
+ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and NO push, NO restart (the platform draws it)', async () => {
268
+ reset();
269
+ const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
270
+ assert.equal(r.status, 200, r.raw);
271
+ assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
272
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply' }, 'the policy half is untouched');
273
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
274
+ assert.equal(r.body.push.queued, false);
275
+ assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
276
+ assert.match(r.body.push.message, /nothing on your project restarts/);
277
+ assert.equal(enqueueCalls, 0, 'no settings-apply intent for a planet change');
278
+ assert.equal(events.length, 1);
279
+ assert.match(events[0].detail, /planet_template=bead-obsidian-tint, planet_accent=sea/);
280
+ });
281
+
282
+ await ta('null hands the pick back to the platform (stored as null, read back as null)', async () => {
283
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } } });
284
+ const r = await patch('/provisioning/instances/18/settings', { planet_template: null, planet_accent: null });
285
+ assert.equal(r.status, 200, r.raw);
286
+ assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null });
287
+ assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: null, planet_accent: null } }]);
288
+ assert.equal(enqueueCalls, 0);
289
+ });
290
+
291
+ await ta('the same planet re-sent → "No change", nothing written', async () => {
292
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', settings: { planet_template: 'bead-obsidian-tint' } } });
293
+ const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
294
+ assert.equal(r.status, 200, r.raw);
295
+ assert.match(r.body.push.message, /^No change/);
296
+ assert.equal(updateCalls.length, 0);
297
+ });
298
+
299
+ await ta('a planet re-sent over a retired push\'s error_note does NOT re-kick the push (only a policy key does)', async () => {
300
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'co-tenant', error_note: 'settings-apply failed', settings: { planet_template: 'bead-obsidian-tint' } } });
301
+ const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint' });
302
+ assert.equal(r.status, 200, r.raw);
303
+ assert.equal(enqueueCalls, 0, 'the planet never restarts anything');
304
+ const r2 = await patch('/provisioning/instances/18/settings', { joinability: 'apply' });
305
+ assert.equal(r2.status, 200, r2.raw);
306
+ assert.equal(enqueueCalls, 1, 'the policy re-kick (the domain route\'s F19 shape) still works');
307
+ });
308
+
309
+ await ta('a policy change and a planet change in one save: the push queues for the policy, the planet rides along', async () => {
310
+ reset();
311
+ const r = await patch('/provisioning/instances/18/settings', { joinability: 'open', planet_accent: 'gold-soft' });
312
+ assert.equal(r.status, 200, r.raw);
313
+ assert.equal(r.body.push.queued, true);
314
+ assert.equal(r.body.planet.planet_accent, 'gold-soft');
315
+ assert.equal(r.body.saved.joinability, 'open');
316
+ assert.equal(enqueueCalls, 1);
317
+ });
318
+
319
+ await ta('an off-vocabulary planet → 400 validation_failed (a colour is a token NAME, never a hex)', async () => {
320
+ reset();
321
+ for (const body of [{ planet_accent: '#fb9569' }, { planet_accent: 'purple' }, { planet_template: 'cube-gold-wash' }]) {
322
+ const r = await patch('/provisioning/instances/18/settings', body);
323
+ assert.equal(r.status, 400, r.raw);
324
+ assert.equal(r.body.error.code, 'validation_failed');
325
+ }
326
+ assert.equal(updateCalls.length, 0);
327
+ });
328
+
250
329
  await ta('an empty body → 400 bad_settings with a plain sentence naming the keys', async () => {
251
330
  reset();
252
331
  const r = await patch('/provisioning/instances/18/settings', {});
@@ -71,6 +71,12 @@ t('the prefix is the pack\'s, not a guess — a wrong prefix silently never fire
71
71
  assert.ok(!('CLOUDBONGOS_PLATFORM_VISIBILITY' in v));
72
72
  });
73
73
 
74
+ t('the planet never rides the env transport (task 1003347): it is the platform\'s to draw, not the instance\'s to run', () => {
75
+ const vars = provisioning.settingsEnvVars({ settings: { platform_visibility: 'gated', planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }, 'CB');
76
+ for (const k of Object.keys(vars)) assert.doesNotMatch(k, /PLANET/, k);
77
+ assert.doesNotMatch(provisioning.settingsEnvLines({ settings: { planet_template: 'bead-obsidian-tint' } }, 'CB'), /planet|PLANET/);
78
+ });
79
+
74
80
  t('every emitted key is in SETTINGS_VOCAB — the two lists cannot drift', () => {
75
81
  const keys = linesOf(provisioning.settingsEnvLines({}, 'X')).map((l) => l.split('=')[0].replace(/^X_/, '').toLowerCase());
76
82
  assert.deepEqual(keys.sort(), Object.keys(provisioning.SETTINGS_VOCAB).sort(),