@bongos/core 1.19.1045 → 1.19.1047

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 (81) hide show
  1. package/.bongos-core.json +90 -115
  2. package/docs/adr/0337-the-hub-account-is-the-person-a-hall-account-is-their-seat-in-one-project.md +14 -2
  3. package/docs/adr/README.md +1 -1
  4. package/docs/copy-inventory.md +17 -24
  5. package/docs/copy-registry.json +24 -97
  6. package/docs/file-map.md +2 -3
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/recipes/core-release-pipeline.md +19 -0
  9. package/docs/recipes/windows-builders.md +1 -1
  10. package/modules/hall-ui/public/atlas.html +1 -1
  11. package/modules/hall-ui/public/blockers.html +1 -1
  12. package/modules/hall-ui/public/board-room.html +1 -1
  13. package/modules/hall-ui/public/collab.css +1 -1
  14. package/modules/hall-ui/public/collab.html +1 -1
  15. package/modules/hall-ui/public/copy-desk.html +1 -1
  16. package/modules/hall-ui/public/deploy.html +1 -1
  17. package/modules/hall-ui/public/diagrams.html +1 -1
  18. package/modules/hall-ui/public/drachmae.html +1 -1
  19. package/modules/hall-ui/public/fleet.html +1 -1
  20. package/modules/hall-ui/public/gate.html +1 -1
  21. package/modules/hall-ui/public/goals.html +1 -1
  22. package/modules/hall-ui/public/government.html +1 -1
  23. package/modules/hall-ui/public/idea.html +1 -1
  24. package/modules/hall-ui/public/ideas.html +1 -1
  25. package/modules/hall-ui/public/index.html +1 -1
  26. package/modules/hall-ui/public/modules.html +1 -1
  27. package/modules/hall-ui/public/primer.html +1 -1
  28. package/modules/hall-ui/public/profile.html +1 -1
  29. package/modules/hall-ui/public/project-settings.html +1 -1
  30. package/modules/hall-ui/public/ranks.html +1 -1
  31. package/modules/hall-ui/public/roadmap.html +1 -1
  32. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  33. package/modules/hall-ui/public/roster.html +5 -5
  34. package/modules/hall-ui/public/roster.js +6 -7
  35. package/modules/hall-ui/public/roster.states.json +1 -1
  36. package/modules/hall-ui/public/sessions.html +1 -1
  37. package/modules/hall-ui/public/settings.html +2 -2
  38. package/modules/hall-ui/public/settings.js +2 -2
  39. package/modules/hall-ui/public/shell.js +3 -10
  40. package/modules/hall-ui/public/studio.html +1 -1
  41. package/modules/hall-ui/public/style.css +2 -9
  42. package/modules/hall-ui/public/task.html +1 -1
  43. package/modules/hall-ui/public/watch.html +1 -1
  44. package/modules/hall-ui/public/work.html +1 -1
  45. package/modules/hall-ui/records/builders-roster.md +1 -1
  46. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  47. package/modules/platform-identity/account-visibility.js +3 -3
  48. package/modules/platform-identity/platform-identity.js +2 -2
  49. package/modules/platform-identity/routes/profile.js +1 -1
  50. package/modules/platform-identity/routes/sso.js +4 -3
  51. package/modules/platform-identity/scouting-authz.js +2 -2
  52. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  53. package/package-lock.json +2 -2
  54. package/package.json +1 -1
  55. package/release-notes.json +12 -0
  56. package/scripts/gds/go-live.js +101 -5
  57. package/src/bongos/auth.js +9 -24
  58. package/src/bongos/serve-internal.js +13 -19
  59. package/src/module-api.js +1 -1
  60. package/tests/account_privacy_flags.mjs +1 -1
  61. package/tests/auth_resolve_failure.mjs +6 -6
  62. package/tests/core_upgrade_owner_door.mjs +1 -1
  63. package/tests/edit_hints.mjs +8 -8
  64. package/tests/effective_visibility_predicate.mjs +1 -1
  65. package/tests/go_live.mjs +143 -4
  66. package/tests/government_parity.mjs +1 -1
  67. package/tests/hall_builders_page_pass.mjs +2 -2
  68. package/tests/hall_nav.mjs +8 -8
  69. package/tests/hall_page_gate_map.mjs +14 -11
  70. package/tests/hall_roster_world.mjs +47 -78
  71. package/tests/module_contributions.mjs +0 -1
  72. package/tests/nav_permission_atoms.mjs +1 -1
  73. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  74. package/tests/profile_rollup_consent.mjs +4 -4
  75. package/tests/profile_route.mjs +3 -26
  76. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  77. package/modules/hall-ui/public/people.html +0 -79
  78. package/modules/hall-ui/public/people.js +0 -321
  79. package/modules/hall-ui/public/people.states.json +0 -89
  80. package/tests/people_directory_clip.mjs +0 -269
  81. package/tests/people_name_only.mjs +0 -263
@@ -174,9 +174,10 @@ function registerSeams() {
174
174
  // which a database error must never be allowed to say.
175
175
  hiddenGithubIds: (githubIds) => pi.hiddenGithubIds(githubIds, { pool }),
176
176
  // task 1002290 (ADR 0210): "does this hub account own a project?", the
177
- // second audience the /people page gate admits beside `project.curate`.
178
- // The ownership fact lives in this module, so the answer does too; core
179
- // asks through the port and treats an absent one as a denial.
177
+ // second audience a page gate's `alsoAdmits` door admits — today the /deploy
178
+ // shell (requireCorePinPage, ADR 0339); /people, its first user, is deleted
179
+ // (task 1003444). The ownership fact lives in this module, so the answer does
180
+ // too; core asks through the port and treats an absent one as a denial.
180
181
  isProjectOwner: (githubId) => scoutingAuthz.hasOwnerMembership(githubId, { pool }),
181
182
  // task 1002347 (BV1.R28, ADR 0251): the names behind `invites.seeking` —
182
183
  // the creation wizard's "who is worth inviting" list. Consent-filtered by
@@ -1,6 +1,6 @@
1
1
  // modules/platform-identity/scouting-authz.js — WHO may read the cross-project
2
- // recruiting directory (GET /scouting; the hall's /people page renders the same
3
- // data). ADR 0210, task 1002290 — goal 1000045 C5: "project owners (not only
2
+ // recruiting directory (GET /scouting; the hall's /people page that rendered it
3
+ // was deleted in task 1003444). ADR 0210, task 1002290 — goal 1000045 C5: "project owners (not only
4
4
  // Archons) browse builder profiles to recruit".
5
5
  //
6
6
  // TWO DOORS, DELIBERATELY DIFFERENT KINDS — the modules/lifecycle/goal-authz.js
@@ -680,7 +680,7 @@ await ta('listScoutingRollup scalar aggregation is fan-out-free (task 1002911)',
680
680
  });
681
681
 
682
682
  await ta('listScoutingRollup enforces consent IN the query (ADR 0171 D2/D3)', async () => {
683
- // /people and /scouting must honor hide BY CONSTRUCTION: the predicate lives
683
+ // /scouting must honor hide BY CONSTRUCTION: the predicate lives
684
684
  // in the scalar-aggregation CTE, not in any caller. Semantics proven against
685
685
  // real SQL by tests/profile_rollup_consent.mjs.
686
686
  const pool = mockPool([{ rows: [] }]);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1045",
3
+ "version": "1.19.1047",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1045",
9
+ "version": "1.19.1047",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1045",
3
+ "version": "1.19.1047",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7571,5 +7571,17 @@
7571
7571
  "id": "1003458",
7572
7572
  "text": "The Tasks board and the Goals page now claim and release work through one shared piece of code instead of two copies that had already drifted apart — the same refusal was worded differently on each page, and one had lost its \""
7573
7573
  }
7574
+ ],
7575
+ "1.19.1046": [
7576
+ {
7577
+ "id": "1003444",
7578
+ "text": "The hall's People page is gone, as you asked. An old People link now opens the Builders page instead, and People is no longer in the side menu. Searching for builders across projects comes back when the hub's community page is"
7579
+ }
7580
+ ],
7581
+ "1.19.1047": [
7582
+ {
7583
+ "id": "1004306",
7584
+ "text": "An update that would be refused no longer takes a database backup first, and the server keeps only the ten newest pre-update backups, so frequent update checks can't fill the disk."
7585
+ }
7574
7586
  ]
7575
7587
  }
@@ -571,7 +571,23 @@ function renderReport(r) {
571
571
  L.push(` Go-live · ${r.targetName}`);
572
572
  L.push(BAR);
573
573
  L.push(` ${r.from} → ${r.to}`);
574
- for (const step of r.steps || []) L.push(` ${step.ok ? '✓' : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
574
+ for (const step of r.steps || []) L.push(` ${step.ok ? (step.warn ? '⚠' : '✓') : '✖'} ${step.name}${step.detail ? ` — ${step.detail}` : ''}`);
575
+
576
+ // A refused rehearsal moved nothing, so the pin and read-back lines below would only
577
+ // alarm: "pin committed NO — the next reset will revert this bump" about a bump that
578
+ // never started (task 1004306).
579
+ if (r.failedAt === 'PREFLIGHT') {
580
+ const ex = failureExcerpt(r.raw);
581
+ if (ex.length) {
582
+ L.push('');
583
+ L.push(' what the upgrade said:');
584
+ for (const l of ex) L.push(` ${l}`);
585
+ }
586
+ L.push('');
587
+ L.push(` Nothing changed — still on ${r.from || 'the current core'}. No timer was stopped and no backup was written.`);
588
+ L.push('');
589
+ return L.join('\n');
590
+ }
575
591
 
576
592
  if (!r.ok) {
577
593
  const ex = failureExcerpt(r.raw);
@@ -681,24 +697,96 @@ function pinStep(mode, pinDirty, head) {
681
697
  };
682
698
  }
683
699
 
700
+ // How many pre-upgrade dumps a backupDir keeps (task 1004306). Nothing pruned them, so
701
+ // the directory grew by one dump per attempt for as long as the lane ran: 37 dumps and
702
+ // 1.7 GB on cloudbongos.com by 2026-09-26, at about ONE attempt a day. The count, not an age,
703
+ // is the bound because the cadence is what varies — ten is ten at daily or at every
704
+ // fifteen minutes. These are the extra copies taken right before a pin move; the
705
+ // nightly backup (db-backup-nightly.sh, 30 days) is the history and is not touched.
706
+ const PRE_UPGRADE_KEEP = 10;
707
+ // Exactly the name Phase A writes: pre-<version>-<YYYYMMDDTHHMMSSZ>.sql.gz. A looser
708
+ // `pre-*` would also take a dump an operator named by hand — the box has a
709
+ // `pre-lane-sweep-<ts>.sql.gz` in that directory, and it is not ours to delete.
710
+ const D8 = '[0-9]'.repeat(8);
711
+ const D6 = '[0-9]'.repeat(6);
712
+ const PRE_UPGRADE_GLOB = `pre-[0-9]*-${D8}T${D6}Z.sql.gz`;
713
+
714
+ // Delete all but the newest PRE_UPGRADE_KEEP pre-upgrade dumps and print how many went.
715
+ // Newest by mtime, never by name: the name sorts 1.19.99 after 1.19.100. It runs only
716
+ // after a dump succeeded, so a run of failures can never empty the directory, and the
717
+ // dump just written is always the newest and always kept. No `$` anywhere, so the
718
+ // outer shell that feeds this to `bash -c` expands nothing in it.
719
+ function pruneCommand(dir, keep = PRE_UPGRADE_KEEP) {
720
+ return `bash -c "cd ${dir} && ls -1t -- ${PRE_UPGRADE_GLOB} 2>/dev/null | tail -n +${keep + 1} | xargs -r rm -v -- | wc -l"`;
721
+ }
722
+
723
+ // Read the prune section back into a step. A prune that could not run is a WARNING,
724
+ // not a failure: the dump it follows is good, and refusing the upgrade over a full
725
+ // cleanup would trade a real deploy for tidiness. It still says so on screen.
726
+ function pruneStep(section, keep = PRE_UPGRADE_KEEP) {
727
+ const name = `prune old pre-upgrade backups (keep the newest ${keep})`;
728
+ const s = String(section == null ? '' : section).trim();
729
+ if (/^\d+$/.test(s)) return { name, ok: true, detail: s === '0' ? 'nothing to remove' : `removed ${s}` };
730
+ return { name, ok: true, warn: true, detail: `could not prune${s ? ` (${s.split('\n').pop()})` : ''} — the directory will keep growing until this is fixed` };
731
+ }
732
+
684
733
  function applyGoLive(target, targetName, targetVersion, from, opts = {}) {
685
734
  const pkg = target.registryPackage || '@bongos/core';
686
735
  const pinMode = pinModeOf(target);
687
736
  const steps = [];
737
+ const upgrade = upgradeCommand(target, targetVersion);
738
+
739
+ // Phase 0 — ask the upgrade whether it WILL go, before anything is stopped or copied
740
+ // (task 1004306). `bongos upgrade --dry-run` runs every refusal the real bump has —
741
+ // the artist gate, module compatibility, the database identity, the downgrade guard —
742
+ // and returns before it changes anything. Phase A used to come first, so on
743
+ // cloudbongos.com (2026-09-26 17:10 and 17:15 UTC) each attempt the artist gate
744
+ // refused had already stopped the deploy timer and written a 65 MB dump: one a day at
745
+ // the daily cadence, a full disk in about three days at every fifteen minutes.
746
+ // runGoLive's own gate read did not stop those runs either, and left no trace of what
747
+ // it saw: on --apply a plan with no halts is never printed, notes included. The bump's
748
+ // pre-flight asks the instance directly, and it was the first thing to say no.
749
+ const rehearsal = remote(target, `
750
+ set -uo pipefail
751
+ ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade} --dry-run"`)}
752
+ `, opts);
753
+ steps.push({
754
+ name: 'pre-flight (the upgrade, rehearsed with --dry-run)',
755
+ ok: rehearsal.ok,
756
+ detail: rehearsal.ok ? null : 'refused — nothing was stopped and nothing was backed up',
757
+ });
758
+ if (!rehearsal.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREFLIGHT', refused: true, raw: rehearsal };
688
759
 
689
760
  // Phase A — stop the pull-deploy timer, then back up. The timer's `reset --hard`
690
761
  // is the hazard: it would revert an uncommitted pin mid-flight. A co-tenant has no
691
762
  // such timer (see REQUIRED_FIELDS), so there is nothing to stop and no hazard.
763
+ //
764
+ // The dump must FAIL when pg_dump fails. `pg_dump | gzip > f` without pipefail
765
+ // reports gzip's success, leaving a 20-byte file that reads as a backup — harmless
766
+ // while nothing pruned, but the prune below keeps the newest ten, so a streak of
767
+ // those would retire every real dump (task 1004306). A failed dump removes its file
768
+ // and fails the phase, which stops the go-live before the pin moves: a bump with no
769
+ // backup behind it is not one to make unattended. $TS is expanded once by this
770
+ // script so the dump and its cleanup name the same file.
771
+ const backupDir = target.backupDir || '~/backups';
772
+ const dumpFile = `${backupDir}/pre-${targetVersion}-$TS.sql.gz`;
692
773
  const backup = target.backupDb
693
- ? asOwner(target, `bash -c "mkdir -p ${target.backupDir || '~/backups'} && pg_dump ${target.backupDb} | gzip > ${target.backupDir || '~/backups'}/pre-${targetVersion}-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"`)
774
+ ? asOwner(target, `bash -c "set -o pipefail; mkdir -p ${backupDir} && { pg_dump ${target.backupDb} | gzip > ${dumpFile} || { rm -f ${dumpFile}; exit 1; }; }"`)
694
775
  : 'echo "(no backupDb configured — skipping)"';
776
+ // `|| echo`: under `set -e` a failed prune would otherwise fail the whole phase and
777
+ // stop a deploy whose backup is already safely on disk (see pruneStep).
778
+ const prune = target.backupDb
779
+ ? `echo "##PRUNE##"; ${asOwner(target, pruneCommand(backupDir))} || echo "prune exited non-zero"`
780
+ : '';
695
781
  const phaseA = remote(target, `
696
782
  set -euo pipefail
697
783
  ${SUDO_PREAMBLE}
698
784
  echo "##TIMER##"; ${target.deployTimer
699
785
  ? `$SUDO systemctl stop ${target.deployTimer} && $SUDO systemctl is-active ${target.deployTimer} || true`
700
786
  : 'echo "(no deployTimer configured — no pull-deploy to stop)"'}
787
+ TS="$(date -u +%Y%m%dT%H%M%SZ)"
701
788
  echo "##BACKUP##"; ${backup}
789
+ ${prune}
702
790
  echo "##END##"
703
791
  `, opts);
704
792
  steps.push({
@@ -706,11 +794,19 @@ echo "##END##"
706
794
  ok: phaseA.ok,
707
795
  detail: phaseA.ok ? null : (phaseA.stderr || '').trim().split('\n').pop(),
708
796
  });
709
- if (!phaseA.ok) return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
797
+ if (!phaseA.ok) {
798
+ // The timer may already be stopped — Phase A stops it before the dump, and a dump
799
+ // can now fail on its own (above). Put it back, as a failed upgrade does below.
800
+ if (target.deployTimer) remote(target, `${SUDO_PREAMBLE}\n$SUDO systemctl start ${target.deployTimer} || true`, opts);
801
+ return { ok: false, steps, from, to: targetVersion, targetName, pinMode, failedAt: 'PREPARE', raw: phaseA };
802
+ }
803
+ if (target.backupDb) {
804
+ const m = (phaseA.stdout || '').split('##PRUNE##')[1];
805
+ steps.push(pruneStep(m === undefined ? null : m.split('##')[0]));
806
+ }
710
807
 
711
808
  // Phase B — the upgrade itself. --commit-pin makes the bump durable against the
712
809
  // pull-deploy reset; auto-rollback stays ON (do NOT pass --no-rollback-on-failure).
713
- const upgrade = upgradeCommand(target, targetVersion);
714
810
  const phaseB = remote(target, `
715
811
  set -uo pipefail
716
812
  ${asOwner(target, `bash -c "cd ${target.instanceDir} && ${upgrade}"`)}
@@ -970,6 +1066,6 @@ module.exports = {
970
1066
  loadConfig, selectTarget, validateTarget, planGoLive, artistGateEntries, readArtistGate, ARTIST_GATE_PATH, ARTIST_GATE_LEVELS,
971
1067
  parseMarker, parseMarkerHits,
972
1068
  renderPlan, renderReport, failureExcerpt, asOwner, isSafeToken, refreshMain, isLocalTarget, gitRefExists, SUDO_PREAMBLE,
973
- applyGoLive, upgradeCommand, pinStep, pinModeOf,
1069
+ applyGoLive, upgradeCommand, pinStep, pinModeOf, pruneCommand, pruneStep, PRE_UPGRADE_KEEP, PRE_UPGRADE_GLOB,
974
1070
  CONFIG_TEMPLATE, HALTS, SEMVER_RE, PIN_MODES, DEFAULT_PIN_MODE,
975
1071
  };
@@ -425,17 +425,14 @@ function signInWithReturn(req) {
425
425
  const PAGE_GATES = Object.freeze({
426
426
  requireBuilderPage: Object.freeze({ permission: 'page.view.public', deniedTo: '/builders' }),
427
427
  requireGovernmentPage: Object.freeze({ permission: 'page.view.government', deniedTo: '/builders' }),
428
- // `alsoAdmits` — a SECOND, non-rank audience this page's DATA ROUTE admits, so
429
- // the page can follow it (ADR 0210, task 1002290). /people fronts GET /scouting,
430
- // which since R14 admits a `project.curate` holder OR a PROJECT OWNER; a page
431
- // gate that could only ever ask about a permission would leave the widened
432
- // audience staring at a redirect. The value names a function on the
433
- // `platform-identity.hub` port — the module that owns the ownership fact — and
434
- // is consulted ONLY after the permission check has already refused, so it can
435
- // widen and never narrow. Absent port, absent function, or a throw: no admit.
436
- requireProjectCuratePage: Object.freeze({
437
- permission: 'project.curate', deniedTo: '/builders', alsoAdmits: 'isProjectOwner',
438
- }),
428
+ // `alsoAdmits` (on requireCorePinPage below) — a SECOND, non-rank audience a
429
+ // page's DATA ROUTE admits, so the page can follow it (ADR 0210). A page gate that
430
+ // could only ever ask about a permission would leave the widened audience staring
431
+ // at a redirect. The value names a function on the `platform-identity.hub` port —
432
+ // the module that owns the ownership fact — and is consulted ONLY after the
433
+ // permission check has already refused, so it can widen and never narrow. Absent
434
+ // port, absent function, or a throw: no admit. Its first user, the hall's /people
435
+ // page, was deleted by task 1003444 (ADR 0337 D5).
439
436
  // The /board-room page (ADR 0266) — gated on `board.vote.cast`, the SAME atom
440
437
  // the vote route checks, so the surface's reach equals the right to vote.
441
438
  //
@@ -464,7 +461,7 @@ const PAGE_GATES = Object.freeze({
464
461
  //
465
462
  // SINCE ADR 0339 (task 1004174) its routes admit a SECOND audience — a hosted
466
463
  // project's own owner, for that project only — so the shell follows through the same
467
- // `alsoAdmits` door /people uses (ADR 0210). It admits the SHELL for an owner of any
464
+ // `alsoAdmits` door (ADR 0210). It admits the SHELL for an owner of any
468
465
  // live project; which project they may read or move is still the routes' question,
469
466
  // asked per request against that project's row.
470
467
  requireCorePinPage: Object.freeze({ permission: 'core.pin.move', deniedTo: '/builders', alsoAdmits: 'isProjectOwner' }),
@@ -619,17 +616,6 @@ async function requireGovernmentPage(req, res, next) {
619
616
  return runPageGate('requireGovernmentPage', req, res, next);
620
617
  }
621
618
 
622
- // The /people builder-directory page (task 1002577) — gated on the
623
- // `project.curate` ATOM, the same key its one data route (GET /scouting,
624
- // modules/platform-identity) checks, so the page layer can never drift wider
625
- // than the API it fronts. R14 (goal 1000045, ADR 0210) widened that route to a
626
- // SECOND audience that is not a rank at all — a project owner — so this gate
627
- // carries the same OR through `alsoAdmits` above: the two doors of the page are
628
- // exactly the two doors of the route, and neither layer can drift from it.
629
- async function requireProjectCuratePage(req, res, next) {
630
- return runPageGate('requireProjectCuratePage', req, res, next);
631
- }
632
-
633
619
  // The /board-room page (ADR 0266) — gated on the `board.vote.cast` ATOM, the
634
620
  // same key the vote route checks, so the page layer can never drift NARROWER
635
621
  // than the API it fronts. That is the direction that matters here: the Board
@@ -940,7 +926,6 @@ module.exports = {
940
926
  requireBuilder,
941
927
  requireBuilderPage,
942
928
  requireGovernmentPage,
943
- requireProjectCuratePage,
944
929
  requireBoardVotePage,
945
930
  requireCorePinPage,
946
931
  requireFleetPage,
@@ -936,17 +936,13 @@ function mountInternalSurfaces(app) {
936
936
  // statically, and a comment in that gap drops the regex out of the map, which
937
937
  // makes the page test as UNGATED while looking gated here. Prose goes above.
938
938
  const BOARD_ROOM_PAGE_RE = /^\/builders\/board-room(?:\.html)?\/?$/;
939
- // The /people builder directory (task 1002577): gated on `project.curate`, the
940
- // exact permission its data route (GET /scouting) checks — same posture as the
941
- // /government tab above (the page can never drift wider than the API it fronts).
942
- const PEOPLE_PAGE_RE = /^\/builders\/people(?:\.html)?\/?$/;
943
939
  // /deploy — the owner's deploy door (task 1003159, ADR 0293). Gated on the
944
940
  // `core.pin.move` atom, the same key its preview and its move both check, so the
945
941
  // shell's reach equals the right to press the button. Archon-floor and system:true,
946
942
  // which makes this the narrowest page gate here — correct for the one surface whose
947
943
  // button restarts the live service and changes what code the platform runs.
948
944
  //
949
- // DECLARED HERE, WITH THE OTHER GATES, AND NOT BELOW: the SCOUTING_LEGACY_RE block
945
+ // DECLARED HERE, WITH THE OTHER GATES, AND NOT BELOW: the RETIRED_DIRECTORY_RE block
950
946
  // that follows is a matcher immediately followed by the redirect it feeds, and
951
947
  // tests/hall_roster_world.mjs reads them as one span. Wedging a declaration between
952
948
  // the two separates a matcher from its handler for a reader as well as for that test.
@@ -959,23 +955,24 @@ function mountInternalSurfaces(app) {
959
955
  const DEPLOY_PAGE_RE = /^\/builders\/deploy(?:\.html)?\/?$/;
960
956
  // The fleet cost page (task 1001949), on the atom its one read checks.
961
957
  const FLEET_PAGE_RE = /^\/builders\/fleet(?:\.html)?\/?$/;
962
- // /scouting WAS the legacy twin of /people — the same /api/gds/scouting
963
- // directory, left behind when task 1002577 graduated it into the hall. Task
964
- // 1003470 had already made the two one page (scouting.html rendered people's
965
- // body byte-for-byte and scouting.js was deleted); task 1003440 removes the
966
- // door itself, because a second URL onto one directory is a surface to keep in
967
- // sync for no reader. The URL survives as a 301 so an old bookmark lands on the
968
- // directory instead of a 404. It carries no page gate: the redirect discloses
969
- // nothing, and /people's wall applies the instant the browser follows it.
970
- const SCOUTING_LEGACY_RE = /^\/builders\/scouting(?:\.html)?\/?$/;
958
+ // /people and its legacy twin /scouting were the hall's cross-project builder
959
+ // directory. Task 1003440 deleted the /scouting door; task 1003444 deleted
960
+ // /people itself (owner call, 2026-09-25 — ADR 0337 D5): cross-project
961
+ // discovery belongs to the hub, and /roster is the hall's directory. Both URLs
962
+ // survive as redirects to /roster so a bookmark lands somewhere instead of a
963
+ // 404. 302, not 301: /roster is interim until the hub community page (task
964
+ // 1003363) ships, and a browser caches a 301 past any re-point. No page gate:
965
+ // the redirect discloses nothing, and /roster is deliberately public.
966
+ // GET /scouting's own gate (platform-identity) is untouched by this.
967
+ const RETIRED_DIRECTORY_RE = /^\/builders\/(?:people|scouting)(?:\.html)?\/?$/;
971
968
  app.use((req, res, next) => {
972
969
  if (isModuleSurfaceHost(req)) return next();
973
970
  if (req.method !== 'GET' && req.method !== 'HEAD') return next();
974
- if (!SCOUTING_LEGACY_RE.test(path.posix.normalize(req.path))) return next();
971
+ if (!RETIRED_DIRECTORY_RE.test(path.posix.normalize(req.path))) return next();
975
972
  const qs = req.url.includes('?') ? req.url.slice(req.url.indexOf('?')) : '';
976
973
  // On the builders subdomain the /builders prefix is redundant (the canonical
977
974
  // redirect above strips it), so emit the clean path and save a second hop.
978
- return res.redirect(301, `${isBuildersHost(req) ? '' : '/builders'}/people${qs}`);
975
+ return res.redirect(302, `${isBuildersHost(req) ? '' : '/builders'}/roster${qs}`);
979
976
  });
980
977
 
981
978
  // /deploy EXISTS ONLY WHERE THE DOOR DOES (task 1004296). The owner, 2026-09-25: /deploy is
@@ -1019,9 +1016,6 @@ function mountInternalSurfaces(app) {
1019
1016
  if (BOARD_ROOM_PAGE_RE.test(norm)) {
1020
1017
  return gdsAuth.requireBoardVotePage(req, res, next);
1021
1018
  }
1022
- if (PEOPLE_PAGE_RE.test(norm)) {
1023
- return gdsAuth.requireProjectCuratePage(req, res, next);
1024
- }
1025
1019
  // ADR 0293: the deploy door, on the same atom its two routes check.
1026
1020
  if (DEPLOY_PAGE_RE.test(norm)) {
1027
1021
  return gdsAuth.requireCorePinPage(req, res, next);
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1045'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1047'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -418,7 +418,7 @@ test('the recruiting surface really is exactly two reads, and each one joins REA
418
418
  const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
419
419
  // The directory reads TWO tiers since task 1002291 — ranked, and the hidden
420
420
  // builders it lists by name — and the opt-out must empty both, or "removes you
421
- // from the People directory" is false for a builder who also hid their record.
421
+ // from the recruiting directory" is false for a builder who also hid their record.
422
422
  for (const composed of ['scoutingListableSql', 'scoutingNameOnlySql', 'recruiterSliverVisibleSql']) {
423
423
  const body = av.slice(av.indexOf(`function ${composed}(`));
424
424
  assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
@@ -93,22 +93,22 @@ test('AUDIT: every async middleware mounted directly on a router owns its reject
93
93
  // rot: a NEW bare `async function require*` added later fails this test.
94
94
  // requireBuilder — the API gate. Guarded here (BV1.R109-follow-up).
95
95
  // requireBuilderPage \
96
- // requireGovernmentPage \ all FOUR delegate to runPageGate, guarded by
97
- // requireProjectCuratePage / BV1.R107 (the /people curate gate; the
98
- // requireBoardVotePage / ADR 0266 /board-room vote gate)
96
+ // requireGovernmentPage \ all delegate to runPageGate, guarded by
97
+ // requireBoardVotePage / BV1.R107 (and the ADR 0266 /board-room vote gate)
99
98
  // requireCorePinPage / ADR 0293's /deploy door — the narrowest of them,
100
99
  // on the archon-floor `core.pin.move` atom.
101
100
  // requireFleetPage — task 1001949's /fleet cost page, same delegation.
102
101
  // Task 1003450 removed two more — requireNonXenosPage and the R113
103
102
  // requireGovernmentManagePage — which had both ended up dispatched from no
104
- // route at all; tests/hall_page_gate_map.mjs now fails if that recurs.
103
+ // route at all; tests/hall_page_gate_map.mjs now fails if that recurs. Task
104
+ // 1003444 removed requireProjectCuratePage with the /people page it fronted.
105
105
  // Anything else async and router-mounted must be added with its own try/catch.
106
106
  const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
107
107
  const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
108
- assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireFleetPage', 'requireGovernmentPage', 'requireProjectCuratePage'],
108
+ assert.deepEqual(asyncMw.sort(), ['requireBoardVotePage', 'requireBuilder', 'requireBuilderPage', 'requireCorePinPage', 'requireFleetPage', 'requireGovernmentPage'],
109
109
  'a new async router middleware appeared — give it a try/catch and add it here');
110
110
  // The page gates must actually delegate (that is WHY they are safe).
111
- for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireProjectCuratePage', 'requireBoardVotePage', 'requireCorePinPage', 'requireFleetPage']) {
111
+ for (const name of ['requireBuilderPage', 'requireGovernmentPage', 'requireBoardVotePage', 'requireCorePinPage', 'requireFleetPage']) {
112
112
  const fn = src.slice(src.indexOf(`async function ${name}`));
113
113
  assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
114
114
  }
@@ -28,7 +28,7 @@ const require = createRequire(import.meta.url);
28
28
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
29
 
30
30
  // The session double must be installed BEFORE auth.js loads: auth.js destructures
31
- // lookupSession at require time (the tests/scouting_page_gate.mjs precedent).
31
+ // lookupSession at require time (the tests/page_gate_second_door.mjs precedent).
32
32
  const dbKernel = require('../src/bongos/db-kernel.js');
33
33
  let nextSession = null;
34
34
  dbKernel.lookupSession = async () => nextSession;
@@ -21,7 +21,7 @@ const hints = require(HOOK);
21
21
 
22
22
  test('each generator input maps to its generator; unrelated paths map to nothing', () => {
23
23
  const cases = [
24
- ['modules/hall-ui/public/people.js', 'copy-inventory.js'],
24
+ ['modules/hall-ui/public/roster.js', 'copy-inventory.js'],
25
25
  ['modules/status-ui/public/index.html', 'copy-inventory.js'],
26
26
  ['src/bongos/auth.js', 'gen-repo-map.js'],
27
27
  ['modules/lifecycle/routes/claims.js', 'gen-api-docs.js'],
@@ -39,12 +39,12 @@ test('each generator input maps to its generator; unrelated paths map to nothing
39
39
  });
40
40
 
41
41
  test('rel() reconciles C:\\, C:/ and /c/ shapes between CLAUDE_PROJECT_DIR and file_path', () => {
42
- const want = 'modules/hall-ui/public/people.js';
43
- assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:\\r\\repo'), want);
44
- assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\people.js', 'C:/r/repo'), want);
45
- assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/people.js', 'C:\\r\\repo'), want);
46
- assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/people.js', 'C:/r/repo/'), want);
47
- assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/people.js', '/home/u/repo'), want);
42
+ const want = 'modules/hall-ui/public/roster.js';
43
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:\\r\\repo'), want);
44
+ assert.equal(hints.rel('C:\\r\\repo\\modules\\hall-ui\\public\\roster.js', 'C:/r/repo'), want);
45
+ assert.equal(hints.rel('/c/r/repo/modules/hall-ui/public/roster.js', 'C:\\r\\repo'), want);
46
+ assert.equal(hints.rel('c:/r/repo/modules/hall-ui/public/roster.js', 'C:/r/repo/'), want);
47
+ assert.equal(hints.rel('/home/u/repo/modules/hall-ui/public/roster.js', '/home/u/repo'), want);
48
48
  });
49
49
 
50
50
  test('run as a hook: prints one hint for a generator input, nothing for an unrelated file, exit 0 both times', () => {
@@ -53,7 +53,7 @@ test('run as a hook: prints one hint for a generator input, nothing for an unrel
53
53
  input: JSON.stringify({ tool_input: { file_path: path.join(projectDir, ...relPath.split('/')) } }),
54
54
  encoding: 'utf8', timeout: 15000, env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir },
55
55
  });
56
- const a = run('modules/hall-ui/public/people.js');
56
+ const a = run('modules/hall-ui/public/roster.js');
57
57
  assert.equal(a.status, 0, a.stderr);
58
58
  assert.match(a.stdout, /copy-registry\.json is now stale/);
59
59
  assert.match(a.stdout, /copy-inventory\.js/);
@@ -1122,7 +1122,7 @@ await ta('private accounts are absent from the leaderboard/directory rollup', as
1122
1122
  const rows = await pi.listScoutingRollup({ limit: 50, pool });
1123
1123
  const shown = rows.map((r) => r.github_login).sort();
1124
1124
  assert.deepEqual(shown, ['octo', 'shifted'],
1125
- 'only active, publicly-visible, unhidden accounts reach /people and /scouting — '
1125
+ 'only active, publicly-visible, unhidden accounts reach /scouting — '
1126
1126
  + `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private, legacy never accepted the terms (ADR 0213)`);
1127
1127
  });
1128
1128