@bongos/core 1.19.419 → 1.19.421

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 (61) hide show
  1. package/.bongos-core.json +108 -58
  2. package/.claude/skills/new-project/SKILL.md +22 -11
  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 +5 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0209-one-budget-for-the-account-existence-oracle.md +79 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -5
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +282 -269
  13. package/docs/copy-registry.json +487 -368
  14. package/docs/design/mocks/hub/mine.html +377 -0
  15. package/docs/design/projects-hub-direction-v2.md +25 -1
  16. package/docs/file-map.md +2 -0
  17. package/docs/module-api-changelog.md +4 -0
  18. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  19. package/modules/hall-ui/public/settings.html +3 -2
  20. package/modules/hall-ui/public/settings.js +10 -6
  21. package/modules/onboarding/routes/access-requests.js +16 -3
  22. package/modules/platform-identity/account-visibility.js +94 -3
  23. package/modules/platform-identity/platform-identity.js +50 -55
  24. package/modules/platform-identity/routes/connections.js +3 -3
  25. package/modules/platform-identity/tests/platform-identity.mjs +20 -0
  26. package/modules/provisioning/migrations/provisioning_013_project_detail.sql +54 -0
  27. package/modules/provisioning/onboard-plan.js +13 -0
  28. package/modules/provisioning/provisioning.js +113 -5
  29. package/modules/provisioning/routes/provisioning.js +105 -0
  30. package/modules/provisioning/tests/provisioning.mjs +84 -0
  31. package/modules/ui-design/kit/fixtures/featured-hub.json +1 -0
  32. package/modules/ui-design/kit/fixtures/provisioning-instances-empty.json +4 -0
  33. package/modules/ui-design/kit/fixtures/provisioning-instances.json +99 -0
  34. package/modules/ui-design/kit/lib.js +6 -2
  35. package/modules/ui-design/kit/probe.js +1 -1
  36. package/modules/ui-design/kit/render.js +1 -1
  37. package/modules/ui-design/kit/serve.js +63 -2
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/public-landing/assets/cosmos.css +224 -18
  41. package/public-landing/projects-empty.states.json +49 -0
  42. package/public-landing/projects-floor.states.json +63 -0
  43. package/public-landing/projects.html +273 -93
  44. package/public-landing/projects.probes.json +32 -7
  45. package/public-landing/projects.states.json +10 -6
  46. package/scripts/gds/fitness.js +9 -0
  47. package/scripts/gds/onboard.js +41 -1
  48. package/scripts/gds/provision-net.js +7 -3
  49. package/src/bongos/middleware/rate-limit.js +68 -2
  50. package/src/bongos/routes/auth.js +19 -12
  51. package/src/module-api.js +13 -1
  52. package/tests/account_existence_rate_limit.mjs +375 -0
  53. package/tests/account_privacy_flags.mjs +17 -10
  54. package/tests/effective_visibility_predicate.mjs +891 -0
  55. package/tests/module_api.mjs +1 -0
  56. package/tests/onboard.mjs +56 -0
  57. package/tests/profile_route.mjs +22 -11
  58. package/tests/projects_hub.mjs +102 -5
  59. package/tests/projects_hub_pre_uat.mjs +45 -11
  60. package/tests/provision.mjs +20 -0
  61. package/tests/ui_design_kit.mjs +32 -2
@@ -24,6 +24,7 @@ const PUBLISHED_SURFACE = [
24
24
  'requirePermission', // BV1.R105: the authority-atom gate module routes migrate onto (1.19.0; ADR 0151)
25
25
  'pool', 'getPool', 'instanceDbName', 'withTx', 'getBuilderById',
26
26
  'secretBox', // Gemini-key storage relocated to modules/art-pipeline + its 'art.getBuilderGeminiKeyRow' port (ADR 0102)
27
+ 'accountExistenceReadRateLimit', // task 1003339 (ADR 0209): the shared per-IP budget both account-existence probes spend from — one instance, not a factory, so the budget is genuinely shared
27
28
  'routeRankCheck', 'permissionPathCheck', // BV1.R73: kernel trust classifiers (1.6.0)
28
29
  'repoInfo', 'moduleScopeMap', 'pathMatch', // BV1.R86: kernel repo-identity + scope-wall + touches-overlap for the carved lifecycle (1.12.0)
29
30
  'goalScopeCheck', // BV1.R60: kernel scope wall for the goal-authored-task create gate (1.13.0; ADR 0086 §3)
package/tests/onboard.mjs CHANGED
@@ -31,6 +31,62 @@ test('onboardPlan is the canonical sequence: name+address first, the builders ha
31
31
  assert.ok(keys.indexOf('device-flow') < keys.indexOf('first-sign-in'), 'checkbox prompted before sign-in');
32
32
  });
33
33
 
34
+ test('the OPTIONAL project-detail step sits in the canonical sequence, and optional means skippable (task 1002334)', () => {
35
+ const plan = ob.onboardPlan();
36
+ const keys = plan.map((s) => s.key);
37
+ const step = plan.find((s) => s.key === 'project-detail');
38
+ assert.ok(step, 'the interview is a real step in the sequence, not a wizard-only flourish');
39
+ assert.equal(keys.indexOf('project-detail'), keys.indexOf('name-address') + 1,
40
+ 'it follows the name interview — the project is described before its repo and address are decided');
41
+ assert.equal(step.optional, true, 'the flag is what tells a form it may be walked past');
42
+ assert.equal(step.kind, 'auto',
43
+ "'auto' like name-address: the FORM collects it, so a done panel of outstanding human steps must not list it");
44
+ // one optional step keeps the flag meaningful; a second would need its own rule
45
+ assert.deepEqual(plan.filter((s) => s.optional).map((s) => s.key), ['project-detail']);
46
+ // and the adopt branch carries it identically — the sequence branches only at scaffold
47
+ assert.ok(ob.onboardPlan('adopt').some((s) => s.key === 'project-detail' && s.optional === true));
48
+ });
49
+
50
+ test('every form OFFERS the optional step and names what skipping costs — skill / CLI / wizard / route (task 1002334)', () => {
51
+ const { readFileSync } = require('node:fs');
52
+ const path = require('node:path');
53
+ const read = (rel) => readFileSync(path.join(import.meta.dirname, '..', rel), 'utf8');
54
+
55
+ // the skill — the source of truth for the sequence
56
+ const skill = read('.claude/skills/new-project/SKILL.md');
57
+ assert.match(skill, /OPTIONAL/, 'the canonical runbook marks the step optional');
58
+ assert.match(skill, /team_shape/, 'and names the answers that ride the request');
59
+ assert.match(skill, /dark matter/i, 'and states what skipping costs');
60
+
61
+ // the CLI form
62
+ const cli = read('scripts/gds/onboard.js');
63
+ assert.match(cli, /Press Enter to skip/, 'the terminal form offers the skip in the prompt itself');
64
+ assert.match(cli, /off the public map/, 'and says what a missing description means');
65
+
66
+ // the web form
67
+ const hub = read('public-landing/projects.html');
68
+ assert.match(hub, /id="panel-detail"/, 'the wizard ships the panel');
69
+ assert.match(hub, /id="skipDetail"/, 'with a skip control of its own');
70
+
71
+ // the route that receives it
72
+ const route = read('modules/provisioning/routes/provisioning.js');
73
+ assert.match(route, /description: \{ type: 'string', maxLength: provisioning\.DESCRIPTION_MAX \}/,
74
+ 'the create route accepts the description');
75
+ assert.match(route, /\/provisioning\/instances\/:id\/detail/,
76
+ 'and there is a way back, or "optional" would mean permanently unpublishable');
77
+ });
78
+
79
+ test('the answers the CLI collects are the same body the web form files (buildProjectDetail)', () => {
80
+ assert.deepEqual(ob.buildProjectDetail({ description: ' a b ', teamShape: 'Community' }),
81
+ { description: 'a b', detail: { team_shape: 'community' } });
82
+ // a skip files nothing — absent means skipped, and an empty string would read
83
+ // to the publish gate as an answered-but-blank description
84
+ assert.deepEqual(ob.buildProjectDetail({}), {});
85
+ assert.deepEqual(ob.buildProjectDetail({ description: ' ', teamShape: '' }), {});
86
+ // an off-vocabulary answer is dropped, never guessed into a recommendation
87
+ assert.deepEqual(ob.buildProjectDetail({ teamShape: 'a big guild' }), {});
88
+ });
89
+
34
90
  test('onboardPlan(adopt): same sequence + human steps, but the scaffold-leg labels branch (task 2046)', () => {
35
91
  const green = ob.onboardPlan();
36
92
  const adopt = ob.onboardPlan('adopt');
@@ -341,6 +341,7 @@ await t('hub port returns null (hidden/provisional/none) → cross_project: null
341
341
  const HUB_GITHUB_ID = FULL_ROW.github_id;
342
342
  const HIDDEN_LOGIN = 'hidden-DO-NOT-LEAK-r06';
343
343
  const PROV_LOGIN = 'provisional-DO-NOT-LEAK-r06';
344
+ const PRIVATE_LOGIN = 'private-DO-NOT-LEAK-r06';
344
345
 
345
346
  // TWO projects — one publicly catalogued, one not. Distinctive digits so the
346
347
  // "no partial numbers" scan below cannot pass by coincidence.
@@ -353,22 +354,28 @@ const PROOF_PROJECTS = [
353
354
  const sumOf = (k) => PROOF_PROJECTS.reduce((n, p) => n + p[k], 0);
354
355
 
355
356
  // The one account row the reads consult. setAccountHideStats mutates it.
356
- const hubAccount = { profile_state: 'active', hide_stats: false, handle: 'r06-handle' };
357
+ const hubAccount = { profile_state: 'active', account_visibility: 'public', hide_stats: false, handle: 'r06-handle' };
357
358
 
358
- // The scouting "table": one visible builder plus the two that consent excludes.
359
+ // The scouting "table": one visible builder plus the THREE that consent and
360
+ // visibility exclude (the private row joined at task 1002967 — the same axis,
361
+ // enforced by the same shared predicate).
359
362
  const SCOUTING_TABLE = [
360
- { profile_state: 'active', hide_stats: false,
363
+ { profile_state: 'active', account_visibility: 'public', hide_stats: false,
361
364
  row: { github_id: '11', github_login: 'r06-visible', display_name: 'Visible', avatar_url: null,
362
365
  project_count: 2, total_credits: 5, total_tasks_shipped: 1, total_karma: 0,
363
366
  last_active: '2026-08-01T00:00:00.000Z', disciplines: ['engineer'] } },
364
- { profile_state: 'active', hide_stats: true,
367
+ { profile_state: 'active', account_visibility: 'public', hide_stats: true,
365
368
  row: { github_id: '12', github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null,
366
369
  project_count: 7, total_credits: 88817, total_tasks_shipped: 6613, total_karma: 5519,
367
370
  last_active: '2026-08-02T00:00:00.000Z', disciplines: ['ui'] } },
368
- { profile_state: 'provisional', hide_stats: false,
371
+ { profile_state: 'provisional', account_visibility: 'public', hide_stats: false,
369
372
  row: { github_id: '13', github_login: PROV_LOGIN, display_name: 'Provisional', avatar_url: null,
370
373
  project_count: 1, total_credits: 71713, total_tasks_shipped: 1, total_karma: 0,
371
374
  last_active: '2026-08-03T00:00:00.000Z', disciplines: [] } },
375
+ { profile_state: 'active', account_visibility: 'private', hide_stats: false,
376
+ row: { github_id: '14', github_login: PRIVATE_LOGIN, display_name: 'Private', avatar_url: null,
377
+ project_count: 3, total_credits: 63127, total_tasks_shipped: 4409, total_karma: 2287,
378
+ last_active: '2026-08-04T00:00:00.000Z', disciplines: ['engineer'] } },
372
379
  ];
373
380
 
374
381
  const engine = {
@@ -382,6 +389,7 @@ const engine = {
382
389
  if (/AS project_count/.test(sql)) {
383
390
  let rows = SCOUTING_TABLE;
384
391
  if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
392
+ if (/pa\.account_visibility = 'public'/.test(sql)) rows = rows.filter((r) => r.account_visibility === 'public');
385
393
  if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
386
394
  return { rows: rows.map((r) => ({ ...r.row })) };
387
395
  }
@@ -390,9 +398,11 @@ const engine = {
390
398
  hubAccount.hide_stats = !!params[4];
391
399
  return { rows: [{ github_id: String(HUB_GITHUB_ID), github_login: 'jaxri', ...hubAccount }] };
392
400
  }
393
- // getPublicProfileExtras' consent read.
394
- if (/SELECT profile_state, hide_stats/.test(sql)) {
395
- return { rows: [{ profile_state: hubAccount.profile_state, hide_stats: hubAccount.hide_stats }] };
401
+ // getPublicProfileExtras' consent read — three columns since task 1002967
402
+ // wired the existence axis in (the shared rollupIsVisible port reads them).
403
+ if (/SELECT profile_state, account_visibility, hide_stats/.test(sql)) {
404
+ return { rows: [{ profile_state: hubAccount.profile_state,
405
+ account_visibility: hubAccount.account_visibility, hide_stats: hubAccount.hide_stats }] };
396
406
  }
397
407
  // getRollupForBuilder's per-project rows (the client_id → origin two-hop).
398
408
  if (/platform_identity_sso_clients/.test(sql)) {
@@ -537,14 +547,15 @@ scoutApp.use(attachFail);
537
547
  scoutApp.use('/api/gds', buildScoutingRoutes());
538
548
  const scout = await listen(scoutApp);
539
549
 
540
- await t('R06.3 GET /scouting drops the hidden AND the provisional builder', async () => {
550
+ await t('R06.3 GET /scouting drops the hidden, the provisional AND the private builder', async () => {
541
551
  engine.calls.length = 0;
542
552
  const r = await get(scout.port, '/api/gds/scouting');
543
553
  assert.equal(r.status, 200);
544
554
  const logins = r.json.builders.map((b) => b.github_login);
545
- assert.deepEqual(logins, ['r06-visible'], 'only the consented, non-hidden builder is listed');
555
+ assert.deepEqual(logins, ['r06-visible'],
556
+ 'only the consented, publicly-visible, non-hidden builder is listed (task 1002967 added the visibility axis)');
546
557
  assert.equal(engine.calls.length, 1, 'exactly ONE read — nothing can re-enter via a second, unfiltered query');
547
- for (const s of [HIDDEN_LOGIN, PROV_LOGIN, '88817', '6613', '5519', '71713']) {
558
+ for (const s of [HIDDEN_LOGIN, PROV_LOGIN, PRIVATE_LOGIN, '88817', '6613', '5519', '71713', '63127', '4409', '2287']) {
548
559
  assert.ok(!r.text.includes(s), `the excluded builder leaks nothing — found "${s}"`);
549
560
  }
550
561
  });
@@ -172,12 +172,40 @@ test('orb accents are tokens, never literal hexes (§6)', () => {
172
172
  for (const t of ['--sea', '--terracotta', '--accent', '--sun-lit', '--gold-soft']) {
173
173
  assert.match(accents, new RegExp(`var\\(${t}\\)`), `${t} must be one of the orb accents`);
174
174
  }
175
- const glows = (SCRIPT.match(/var ORB_GLOWS = ([^;]*);/) || [, ''])[1];
176
- assert.doesNotMatch(glows, /rgba?\(/, 'orb glows must derive from the accents, not literal rgba');
175
+ // the sphere's glow left with the sphere (task 1003345): a planet carries its accent only
176
+ assert.doesNotMatch(SCRIPT, /ORB_GLOWS|--glow:/, 'no glow — the row draws the sky\'s planet');
177
177
  assert.equal((SCRIPT.match(/var ORB_ACCENTS/g) || []).length, 1,
178
178
  'one palette, not two — the map and the manage cards must not drift apart');
179
179
  });
180
180
 
181
+ /* ── the row's planet is the sky's object (task 1003345, direction record §14: v1 the ledger row) ── */
182
+ test('the mine row draws the project\'s planet — the catalog\'s template when matched, the slug walk otherwise', () => {
183
+ const orb = (SCRIPT.match(/function orbFor\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
184
+ assert.match(orb, /var pl = planetOf\(inst\);/, 'orbFor asks planetOf');
185
+ assert.match(orb, /class="' \+ cls \+ '"' \+ attrs/, 'the planet is a .worldOrb with the template attributes');
186
+ assert.match(orb, /<span class="disc"><\/span>/, 'the disc cosmos.css draws');
187
+ assert.doesNotMatch(orb, /class="orb/, 'the sphere is gone');
188
+ const planet = (SCRIPT.match(/function planetOf\(inst\) \{([\s\S]*?)\n \}/) || [, ''])[1];
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');
192
+ assert.match(SCRIPT, /assignAccents\(\); applyMapFilter\(\); repaintPlanets\(\);/, 'a catalog that lands late repaints the rows');
193
+ assert.doesNotMatch(COSMOS, /\n\.orb\{/, 'cosmos no longer carries the sphere');
194
+ });
195
+
196
+ test('the mine list lays every row on one set of FIXED tracks (task 1003348) and the manage h1 stands outside #mMain', () => {
197
+ assert.match(COSMOS, /#view-mine \.projBody\{display:grid;grid-template-columns:minmax\(0,1\.1fr\) minmax\(0,1\.3fr\) minmax\(0,1fr\) 232px;/, 'no content minimum, so the tracks are the same on every row');
198
+ assert.match(COSMOS, /#view-mine \.proj\{display:grid;grid-template-columns:72px minmax\(0,1fr\);/, 'the planet column is a width');
199
+ assert.match(COSMOS, /#view-mine \.card,#view-manage \.card\{background:none;/, 'no card ground on either view');
200
+ assert.match(COSMOS, /#view-mine \.strip,#view-manage \.strip\{border:0;border-radius:0;padding:0;background:none;/, 'the strip is drawn as the rail');
201
+ assert.doesNotMatch(COSMOS.slice(COSMOS.indexOf('YOUR PROJECTS + THE MANAGE PAGE')), /box-shadow:(?!none)/, 'halos are outlines in the world, never box-shadow');
202
+ const title = HUB.indexOf('<h1 id="mTitle">');
203
+ const main = HUB.indexOf('<section id="mMain"');
204
+ assert.ok(title > -1 && main > -1 && title < main, 'the h1 renders in every manage state — signed out, not found, loading');
205
+ assert.match(HUB, /<h1 id="mTitle"><span id="mName">Manage<\/span><\/h1>/, '#mName is the heading\'s text, so renderManage names the page by filling it');
206
+ for (const id of ['mOrb', 'mFacts', 'mPlanet']) assert.match(HUB, new RegExp(`id="${id}"`), `#${id} is real markup`);
207
+ });
208
+
181
209
  test('accents are assigned by canonical position, not by hashing the name', () => {
182
210
  // a name hash collided: with five tokens and four projects it put three of
183
211
  // them on the same ember and the sky stopped being readable
@@ -613,6 +641,75 @@ test('every table column has a source — Type is declared, never guessed', () =
613
641
  assert.match(SCRIPT, /type: state\.projType/, 'the create request carries the declared type');
614
642
  });
615
643
 
644
+ test('every per-step hook in show() still points at the panel it belongs to (renumbering guard)', () => {
645
+ // The renumbering risk is NOT the data-next attributes (guarded above) — it is
646
+ // show()'s side-effect hooks, which key off bare integers. Point one at the
647
+ // wrong panel and the wizard still navigates perfectly while silently never
648
+ // probing repo access, never re-checking the handle, or rendering a stale
649
+ // review. Derived from the panels map so this survives the next renumbering.
650
+ const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
651
+ const STEP = {};
652
+ for (const m of map.matchAll(/(\d+):\s*'([a-z-]+)'/g)) STEP[m[2]] = Number(m[1]);
653
+ const show = SCRIPT.slice(SCRIPT.indexOf('function show(step, moveFocus)'));
654
+ const body = show.slice(0, show.search(/\n\s*\/\* One delegated pair on the rail/));
655
+
656
+ assert.ok(body.includes(`if (step === ${STEP['panel-home']}) { syncHome();`),
657
+ 'the repo probe fires on the Home panel');
658
+ assert.ok(body.includes(`if (step === ${STEP['panel-address']}) { checkSlugAvailable(); renderAddrPanel(); }`),
659
+ 'the handle re-check fires on the Address panel');
660
+ assert.ok(body.includes(`if (step === ${STEP['panel-review']}) renderReview();`),
661
+ 'the review is rendered on the Review panel');
662
+ assert.ok(body.includes(`'wizDone', step === ${STEP['panel-done']}`),
663
+ 'the done panel is the one that takes the whole card');
664
+ // and the About panel claims no hook of its own — it has nothing to fetch
665
+ assert.ok(!body.includes(`if (step === ${STEP['panel-detail']})`),
666
+ 'the optional step needs no side effect; adding one would give it a way to fail');
667
+ });
668
+
669
+ test('the About step is OFFERED by every form and can be walked past — the skip is a real control (task 1002334)', () => {
670
+ const about = (HUB.match(/<section class="panel" id="panel-detail"[\s\S]*?<\/section>/) || [''])[0];
671
+ assert.ok(about, '#panel-detail ships as real markup');
672
+ assert.match(about, /id="f-desc"/, 'it asks what the owner is building — the publish gate half');
673
+ assert.match(about, /id="teamCards"/, 'and who is building it — what the recommendations read');
674
+ // the skip must be its OWN button: "leave it blank and press Next" is an
675
+ // escape hatch the visitor has to infer, which is not an optional step
676
+ assert.match(about, /id="skipDetail"/, 'the skip is a control, not an inference');
677
+ assert.match(about, /Optional/, 'and the panel says so before it asks');
678
+ // the consequence of skipping is stated where the choice is made
679
+ assert.match(SCRIPT, /stays off the public map until it has one/,
680
+ 'skipping names its cost (ADR 0182 D4), never leaves it to be discovered later');
681
+ // nothing in validateStep may gate the About step
682
+ const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
683
+ const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
684
+ const aboutStep = Number((map.match(/(\d+):\s*'panel-detail'/) || [, 0])[1]);
685
+ assert.ok(aboutStep > 0, 'the About panel is in the map');
686
+ assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${aboutStep}) {`),
687
+ 'the optional step has no validation clause — Next past an empty answer is the point');
688
+ });
689
+
690
+ test('the About answers travel on the create request, and an empty one is OMITTED not blanked', () => {
691
+ assert.match(SCRIPT, /description: String\(state\.description \|\| ''\)\.trim\(\) \|\| undefined/,
692
+ 'a skipped description is absent from the body — an empty string would read as answered');
693
+ assert.match(SCRIPT, /detail: state\.teamShape \? \{ team_shape: state\.teamShape \} : undefined/,
694
+ 'and an unanswered team shape is never guessed into the request');
695
+ // the review is the last screen before Create, so the skip's cost is restated there
696
+ const review = (SCRIPT.match(/function renderReview\(\)\s*\{([\s\S]*?)\n \}/) || [, ''])[1];
697
+ assert.match(review, /off the public map until you add it/,
698
+ 'the review row states the consequence rather than a bare dash');
699
+ assert.match(review, /\['About',/, 'and the About answer has a row of its own');
700
+ });
701
+
702
+ test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
703
+ // task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again.
704
+ // A draft replayed under a different numbering lands on the wrong panel with the
705
+ // rail already marking the skipped steps done.
706
+ const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
707
+ assert.match(key, /-v\d+$/, 'the draft key is versioned');
708
+ const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
709
+ const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
710
+ assert.equal(count, 8, 'gate + six numbered steps + done — bump DRAFT_KEY when this changes');
711
+ });
712
+
616
713
  test('header and body agree on the column count', () => {
617
714
  // a stray <td> shifts every cell after it — silent, and only visible on screen
618
715
  const n = ((HUB.match(/<thead>([\s\S]*?)<\/thead>/) || [, ''])[1].match(/<th/g) || []).length;
@@ -1173,7 +1270,7 @@ test('the delete section is live markup with its body slot (C7)', () => {
1173
1270
  assert.match(HUB, /id="mDel"/, 'the volatile slot is real markup, not JS-built');
1174
1271
  assert.match(HUB, /id="mDelWhat"/, 'the teardown-vs-delete fold ships with the page');
1175
1272
  // the fold is a SIBLING of the volatile slot, so no re-render can drop it
1176
- const card = (HUB.match(/<div class="card" id="mDelete">[\s\S]*?\n <\/div>/) || [''])[0];
1273
+ const card = (HUB.match(/<div class="card mSec" id="mDelete">[\s\S]*?\n <\/div>/) || [''])[0];
1177
1274
  assert.ok(card, 'the delete card must be extractable');
1178
1275
  const slotAt = card.indexOf('id="mDel"');
1179
1276
  const foldAt = card.indexOf('id="mDelWhat"');
@@ -1253,7 +1350,7 @@ const fnHub = (name) => {
1253
1350
  test('the visibility section is live markup with its body slot and a sibling fold (C7)', () => {
1254
1351
  assert.match(HUB, /id="mVis"/, 'the volatile slot is real markup, not JS-built');
1255
1352
  assert.match(HUB, /id="mVisWhat"/, 'the what-changes fold ships with the page');
1256
- const card = (HUB.match(/<div class="card" id="mVisibility">[\s\S]*?\n <\/div>/) || [''])[0];
1353
+ const card = (HUB.match(/<div class="card mSec" id="mVisibility">[\s\S]*?\n <\/div>/) || [''])[0];
1257
1354
  assert.ok(card, 'the visibility card must be extractable');
1258
1355
  const slotAt = card.indexOf('id="mVis"');
1259
1356
  const foldAt = card.indexOf('id="mVisWhat"');
@@ -1335,7 +1432,7 @@ test('the settings reads fire on entry + tab return only — never on the in-fli
1335
1432
  test('the joinability section is live markup with its body slot and a sibling fold', () => {
1336
1433
  assert.match(HUB, /id="mJoin"/, 'the volatile slot is real markup, not JS-built');
1337
1434
  assert.match(HUB, /id="mJoinWhat"/, 'the what-changes fold ships with the page');
1338
- const card = (HUB.match(/<div class="card" id="mJoinability">[\s\S]*?\n <\/div>/) || [''])[0];
1435
+ const card = (HUB.match(/<div class="card mSec" id="mJoinability">[\s\S]*?\n <\/div>/) || [''])[0];
1339
1436
  assert.ok(card, 'the joinability card must be extractable');
1340
1437
  const slotAt = card.indexOf('id="mJoin"');
1341
1438
  const foldAt = card.indexOf('id="mJoinWhat"');
@@ -28,6 +28,20 @@ const fn = (name) => {
28
28
  return next < 0 ? rest : rest.slice(0, next);
29
29
  };
30
30
 
31
+ // The step indices are DERIVED from the panels map, not typed (task 1002334
32
+ // renumbered them a second time and every literal below broke). What these
33
+ // blocks actually claim is "the DONE panel" and "the step before it" — say
34
+ // that, and the next renumbering costs nothing.
35
+ const PANELS = (() => {
36
+ const m = SCRIPT.match(/var panels = \{([^}]*)\}/);
37
+ assert.ok(m, 'the panels map must exist');
38
+ const map = {};
39
+ for (const p of m[1].matchAll(/(\d+):\s*'([a-z-]+)'/g)) map[Number(p[1])] = p[2];
40
+ return map;
41
+ })();
42
+ const STEP = Object.fromEntries(Object.entries(PANELS).map(([n, id]) => [id, Number(n)]));
43
+ const DONE = STEP['panel-done'];
44
+
31
45
  /* ── the gate says why GitHub will ask for repository access ── */
32
46
  test('the gate explains the repo scope BEFORE GitHub does — on the path every founder takes', () => {
33
47
  const gate = (HUB.match(/<section class="panel" id="panel-gate"[\s\S]*?<\/section>/) || [''])[0];
@@ -203,7 +217,9 @@ test('the wizard’s 401 recoveries keep the repo scope they already granted', (
203
217
  });
204
218
 
205
219
  test('a resumed draft past Home still probes repo access before Create', () => {
206
- assert.match(SCRIPT, /else if \(step > 3 && step < 6 && authKnown && repoAccess === null\) ensureRepoAccess\(\);/);
220
+ assert.ok(SCRIPT.includes(
221
+ `else if (step > ${STEP['panel-home']} && step < ${DONE} && authKnown && repoAccess === null) ensureRepoAccess();`),
222
+ 'every step between Home and the done panel re-probes');
207
223
  });
208
224
 
209
225
  test('a half-typed custom domain says why Next is dead; a taken handle silences the format cheer', () => {
@@ -288,9 +304,19 @@ test('the bar is the page ground closed by a hairline, holds one row on a phone,
288
304
  assert.match(HUB, /#view-mine \.hero,#view-manage \.hero\{max-width:none;\}/, 'titles align with their cards');
289
305
  });
290
306
 
291
- test('card metadata is span-wrapped so a separator can never strand', () => {
307
+ test('card metadata is span-wrapped so a separator can never strand; the manage page lays its facts as a ledger', () => {
292
308
  assert.match(fn('renderCard'), /meta\.map\(function \(m\) \{ return '<span>' \+ m \+ '<\/span>'; \}\)\.join\(''\)/);
293
- assert.match(fn('renderManage'), /meta\.map\(function \(m\) \{ return '<span>' \+ m \+ '<\/span>'; \}\)\.join\(''\)/);
309
+ /* task 1003345: the manage page's facts are label · value rows on hairlines — one
310
+ pair per fact, each fact keeping its id so a missing answer still hides */
311
+ assert.match(SCRIPT, /function ledgerRow\(r\) \{ return '<span class="k">' \+ r\[0\] \+ '<\/span><span class="v">' \+ r\[1\] \+ '<\/span>'; \}/);
312
+ const rm = fn('renderManage');
313
+ assert.match(rm, /\$\('mMeta'\)\.innerHTML = rows\.map\(ledgerRow\)\.join\(''\);/);
314
+ assert.match(rm, /\$\('mAddr'\)\.innerHTML = addr \? ledgerRow\(\['address', addr\]\) : '';/);
315
+ assert.match(rm, /paintManagePlanet\(inst\);/, 'the head and the planet row paint through one fn');
316
+ const pm = fn('paintManagePlanet');
317
+ assert.match(pm, /\$\('mPlanet'\)\.innerHTML = ledgerRow\(\['planet', planetWords\(pl\)/, 'the planet row is the slot the picker (task 1003347) fills');
318
+ assert.match(pm, /\$\('mOrb'\)\.innerHTML = orbFor\(inst\);/, 'the same planet the row shows, at the head');
319
+ assert.match(fn('repaintPlanets'), /if \(manageInst\) paintManagePlanet\(manageInst\);/, 'a catalog that lands after the manage page rendered repaints its head too');
294
320
  });
295
321
 
296
322
  test('an open teardown confirm survives the minute re-render', () => {
@@ -377,7 +403,7 @@ test('a heartbeat re-render keeps an open fold open and a half-typed domain type
377
403
 
378
404
  /* ── the owner's five calls of 2026-08-24 (task 1003250) ── */
379
405
  test('decision 6 — the manage section is "Take it offline", and the teardown truth ends by saying nothing erases', () => {
380
- const del = (HUB.match(/<div class="card" id="mDelete">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
406
+ const del = (HUB.match(/<div class="card mSec" id="mDelete">[\s\S]*?<\/details>\s*<\/div>/) || [''])[0];
381
407
  assert.ok(del, '#mDelete exists');
382
408
  assert.match(del, /<h2[^>]*>Take it offline<\/h2>/, 'the heading says what the control does');
383
409
  assert.doesNotMatch(HUB, /Delete the project/, 'the word "Delete" no longer promises an erase anywhere on the page');
@@ -447,13 +473,16 @@ test('a manifest error says a plain sentence, folds the route text, and sends a
447
473
 
448
474
  test('the rail at the done panel keeps its marker on Create until the project answers', () => {
449
475
  const rail = fn('paintRail');
450
- assert.match(rail, /var landed = step === 6 && !!\(live && live\.instStatus === 'active'\);/);
451
- assert.match(rail, /var cur = step === 6 \? 5 : step;/);
476
+ assert.ok(rail.includes(`var landed = step === ${DONE} && !!(live && live.instStatus === 'active');`),
477
+ 'the rail only finishes on the done panel');
478
+ assert.ok(rail.includes(`var cur = step === ${DONE} ? ${DONE - 1} : step;`),
479
+ 'the done panel keeps the marker on the last numbered beat');
452
480
  assert.match(rail, /li\.classList\.toggle\('done', landed \|\| n < cur\);/);
453
481
  assert.match(rail, /if \(now\) li\.setAttribute\('aria-current', 'step'\); else li\.removeAttribute\('aria-current'\);/);
454
482
  assert.match(fn('show'), /paintRail\(step\);/, 'show() paints through it');
455
483
  assert.doesNotMatch(fn('show'), /classList\.toggle\('done'/, 'and no longer marks the rail on its own');
456
- assert.match(SCRIPT, /live\.instStatus = inst\.status; renderSteps\(\);\n\s*if \(state\.step === 6\) paintRail\(6\);/, 'the done-panel poll repaints when the status moves');
484
+ assert.ok(SCRIPT.includes(`if (state.step === ${DONE}) paintRail(${DONE});`),
485
+ 'the done-panel poll repaints when the status moves');
457
486
  assert.match(HUB, /<li class="now" data-step="1" aria-current="step">/, 'the static rail starts with the same marker');
458
487
  });
459
488
 
@@ -560,7 +589,10 @@ test('the hub has one main landmark, a heading for the map, a footer, and a skip
560
589
  });
561
590
 
562
591
  test('completed rail steps are a way back, the repo picker is honest ARIA, the card inerts the page', () => {
563
- assert.match(fn('paintRail'), /if \(step < 6 && n < cur\) \{\n\s*li\.setAttribute\('role', 'button'\);\n\s*li\.setAttribute\('tabindex', '0'\);\n\s*li\.setAttribute\('data-goto', String\(n\)\);/, 'only answered steps become buttons, and none once the project is filed');
592
+ assert.ok(fn('paintRail').includes(`if (step < ${DONE} && n < cur) {`),
593
+ 'only answered steps become buttons, and none once the project is filed');
594
+ assert.ok(fn('paintRail').includes("li.setAttribute('data-goto', String(n));"),
595
+ 'and an answered step carries the goto the delegated handler reads');
564
596
  assert.match(SCRIPT, /railEl\.addEventListener\('click'/);
565
597
  assert.match(SCRIPT, /railEl\.addEventListener\('keydown'/);
566
598
  assert.match(SCRIPT, /show\(Number\(li\.getAttribute\('data-goto'\)\), true\)/, 'a deliberate step: focus moves');
@@ -582,9 +614,11 @@ test('completed rail steps are a way back, the repo picker is honest ARIA, the c
582
614
  });
583
615
 
584
616
  test('control nits: orb cursor, caret under reduced motion, no mode switch, home label, held reduce-motion query, target sizes', () => {
585
- assert.doesNotMatch(COSMOS, /\.orb\{flex:0 0 54px;[^}]*cursor:pointer/, 'the base orb is not a pointer');
586
- assert.match(COSMOS, /\na\.orb\{cursor:pointer;\}/, 'only the linked orb is');
587
- assert.doesNotMatch(COSMOS, /\.orb\.ghost\{[^}]*cursor:default/);
617
+ /* task 1003345: the row's object is the planet (.worldOrb, docked); only the live
618
+ project's, a link to its site, is a pointer */
619
+ assert.match(COSMOS, /#view-mine \.worldOrb,#view-manage \.worldOrb\{[^}]*cursor:default/, 'the docked planet is not a pointer');
620
+ assert.match(COSMOS, /\n#view-mine a\.worldOrb,#view-manage a\.worldOrb\{cursor:pointer;\}/, 'only the linked one is');
621
+ assert.doesNotMatch(COSMOS, /\n\.orb\{/, 'the sphere is gone');
588
622
  assert.doesNotMatch(HUB, /\.shell \.caret/, 'no caret to hide — the hub mark is static (task 1003300)');
589
623
  assert.doesNotMatch(SCRIPT, /modeToggle|paintModeToggle|resolvedMode|cb-color-mode/, 'the mode switch left the hub — the apex is dark only (task 1003336)');
590
624
  assert.doesNotMatch(HUB, /id="modeToggle"|class="modeBtn"/);
@@ -1673,6 +1673,26 @@ await ta('federateInstance(--apply): registers the client on the hub bound to th
1673
1673
  });
1674
1674
  });
1675
1675
 
1676
+ await ta('federateInstance: the project-detail answers ride to the catalog row — and a SKIP stays skipped (task 1002334)', async () => {
1677
+ await withEnv(HUB_ENV, async () => {
1678
+ // The instance row holds what the owner said at creation; the catalog row is
1679
+ // where the publish gate reads it (ADR 0182 D4). Dropping the answer on this
1680
+ // one hop would leave a project that DID describe itself in dark matter,
1681
+ // which would make the whole optional step decorative.
1682
+ const told = { ...coTenantWithDomain, type: 'game', description: 'A co-op farming game.' };
1683
+ const pi = fakePI();
1684
+ const deps = { apply: true, log: () => {}, platformIdentity: pi, hubKeys: hubKeysOn, db: { query: async () => ({ rows: [{ github_id: 'gh-owner-1' }] }) } };
1685
+ await P.federateInstance(told, deps);
1686
+ assert.equal(pi.calls[0].description, 'A co-op farming game.', 'the description reaches the hub registration');
1687
+ assert.equal(pi.calls[0].type, 'game', 'alongside the declared type it follows');
1688
+
1689
+ // the skipped case: absent, never an empty string invented on the way
1690
+ const skipped = fakePI();
1691
+ await P.federateInstance(coTenantWithDomain, { ...deps, platformIdentity: skipped });
1692
+ assert.equal(skipped.calls[0].description, undefined, 'a skipped interview passes nothing — upsertProject COALESCEs, so it nulls nothing either');
1693
+ });
1694
+ });
1695
+
1676
1696
  await ta('federateInstance(dry-run): registers NOTHING, returns a placeholder block (so webEnvBody can still compose)', async () => {
1677
1697
  await withEnv(HUB_ENV, async () => {
1678
1698
  const pi = fakePI();
@@ -33,11 +33,12 @@ test('the platform floors are a pinned list — a floor cannot vanish quietly',
33
33
  });
34
34
 
35
35
  test('the shipped states files validate and sit beside the page they name', () => {
36
- for (const rel of ['public-landing/projects.states.json', 'public-landing/index.states.json', 'modules/hall-ui/public/ranks.states.json']) {
36
+ for (const rel of ['public-landing/projects.states.json', 'public-landing/projects-empty.states.json', 'public-landing/index.states.json', 'modules/hall-ui/public/ranks.states.json']) {
37
37
  const json = readJson(rel);
38
38
  const v = lib.validateStatesFile(json);
39
39
  assert.deepEqual(v.errors, [], `${rel}: ${v.errors.join('; ')}`);
40
- assert.equal(path.basename(rel), `${lib.pageName(json.page)}.states.json`, `${rel} is named for its page`);
40
+ if (!json.stub || !json.stub.fixtures) assert.equal(path.basename(rel), `${lib.pageName(json.page)}.states.json`, `${rel} is named for its page`);
41
+ else assert.ok(path.basename(rel).startsWith(`${lib.pageName(json.page)}-`), `${rel}: a fixture-aliased sibling is named <page>-<variant>.states.json`);
41
42
  }
42
43
  // the apex is dark only (task 1003336): its states files name the one mode, and
43
44
  // render.js reads it as the default so a plain `--page /` never renders light
@@ -66,6 +67,8 @@ test('a states file that breaks the contract is refused with the reason named',
66
67
  assert.ok(bad.errors.some((e) => /states\.ok\.auth/.test(e)));
67
68
  const stub = lib.validateStatesFile({ page: '/x', stub: { prefix: 'builders' }, states: { a: {} } });
68
69
  assert.ok(stub.errors.some((e) => /stub\.prefix/.test(e)));
70
+ assert.ok(lib.validateStatesFile({ page: '/x', stub: { fixtures: { a: 'Not Kebab' } }, states: { a: {} } }).errors.some((e) => /stub\.fixtures/.test(e)), 'a fixture alias names a kebab-case file');
71
+ assert.deepEqual(lib.validateStatesFile({ page: '/x', stub: { fixtures: { 'provisioning-instances': 'provisioning-instances-empty' } }, states: { a: {} } }).errors, [], 'the empty-list alias is the shape');
69
72
  const modes = lib.validateStatesFile({ page: '/x', modes: ['sepia'], states: { a: {} } });
70
73
  assert.ok(modes.errors.some((e) => e.startsWith('modes:')), 'an unknown mode is refused');
71
74
  assert.ok(lib.validateStatesFile({ page: '/x', modes: [], states: { a: {} } }).errors.some((e) => e.startsWith('modes:')), 'an empty modes list is refused');
@@ -146,6 +149,33 @@ test('checkMock: the platform rules catch a page :root, a literal colour, a pinn
146
149
  assert.ok(f5.some((r) => r.startsWith('every dark rule written twice')), f5.join(' | '));
147
150
  });
148
151
 
152
+ test('serve.js answers the manage flows with the routes\' receipts, signed in only (task 1003345)', async () => {
153
+ const s = await lib.startStub({ surface: 'public-landing', auth: true });
154
+ try {
155
+ const post = (p, method = 'POST', body = '{}') => fetch(s.base + p, { method, headers: { 'content-type': 'application/json' }, body });
156
+ for (const [p, msg] of [['/api/bongos/provisioning/instances/101/teardown', 'Teardown queued.'], ['/api/bongos/provisioning/instances/105/restart', 'Restart queued.'],
157
+ ['/api/bongos/provisioning/instances/101/domain', 'Address recorded.'], ['/api/bongos/provisioning/instances', 'Queued. The platform picks it up from here.']]) {
158
+ const r = await post(p); assert.equal(r.status, 200, p); assert.equal((await r.json()).message, msg, p);
159
+ }
160
+ const patched = await post('/api/bongos/provisioning/instances/101/settings', 'PATCH', '{"joinability":"apply"}');
161
+ assert.equal(patched.status, 200); assert.equal((await patched.json()).push.message, 'Saved. The project restarts to apply it.');
162
+ assert.equal((await post('/api/bongos/provisioning/instances/101/settings')).status, 404, 'settings take PATCH, not POST');
163
+ assert.equal((await post('/api/bongos/provisioning/instances/101/nope')).status, 404, 'no invented flow');
164
+ const asked = await (await fetch(s.base + '/api/bongos/provisioning/instances/101')).json();
165
+ assert.deepEqual(asked.open_intent && { action: asked.open_intent.action, state: asked.open_intent.state }, { action: 'teardown', state: 'queued' }, 'a teardown ask is remembered for the run — the page re-reads the project as queued');
166
+ assert.match(String(asked.open_intent.created_at), /^\d{4}-\d{2}-\d{2}T/, 'stamped like the route stamps it');
167
+ const untouched = await (await fetch(s.base + '/api/bongos/provisioning/instances/104')).json();
168
+ assert.equal(untouched.open_intent, null, 'an instance nobody asked about is unchanged');
169
+ const list = await (await fetch(s.base + '/api/bongos/provisioning/instances')).json();
170
+ assert.equal(list.instances.length, 5, 'the fifth row (Mooring) is served');
171
+ assert.equal(list.instances.find((i) => i.id === 105).open_intent.action, 'restart', 'the list carries the asks too');
172
+ } finally { s.stop(); }
173
+ const out = await lib.startStub({ surface: 'public-landing', auth: false });
174
+ try {
175
+ assert.equal((await fetch(out.base + '/api/bongos/provisioning/instances/101/teardown', { method: 'POST' })).status, 401, 'signed out, every ask is refused');
176
+ } finally { out.stop(); }
177
+ });
178
+
149
179
  test('checkMock: a taste ban applies only when the instance declared it', () => {
150
180
  const dashed = GOOD_MOCK.replace('Plain copy.', 'Plain — copy.');
151
181
  const withBan = mock.checkMock(dashed, { bans: new Set(['em-dash']) });