@bongos/core 1.19.1045 → 1.19.1046

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 (78) hide show
  1. package/.bongos-core.json +87 -112
  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 +2 -0
  8. package/docs/recipes/windows-builders.md +1 -1
  9. package/modules/hall-ui/public/atlas.html +1 -1
  10. package/modules/hall-ui/public/blockers.html +1 -1
  11. package/modules/hall-ui/public/board-room.html +1 -1
  12. package/modules/hall-ui/public/collab.css +1 -1
  13. package/modules/hall-ui/public/collab.html +1 -1
  14. package/modules/hall-ui/public/copy-desk.html +1 -1
  15. package/modules/hall-ui/public/deploy.html +1 -1
  16. package/modules/hall-ui/public/diagrams.html +1 -1
  17. package/modules/hall-ui/public/drachmae.html +1 -1
  18. package/modules/hall-ui/public/fleet.html +1 -1
  19. package/modules/hall-ui/public/gate.html +1 -1
  20. package/modules/hall-ui/public/goals.html +1 -1
  21. package/modules/hall-ui/public/government.html +1 -1
  22. package/modules/hall-ui/public/idea.html +1 -1
  23. package/modules/hall-ui/public/ideas.html +1 -1
  24. package/modules/hall-ui/public/index.html +1 -1
  25. package/modules/hall-ui/public/modules.html +1 -1
  26. package/modules/hall-ui/public/primer.html +1 -1
  27. package/modules/hall-ui/public/profile.html +1 -1
  28. package/modules/hall-ui/public/project-settings.html +1 -1
  29. package/modules/hall-ui/public/ranks.html +1 -1
  30. package/modules/hall-ui/public/roadmap.html +1 -1
  31. package/modules/hall-ui/public/{people.css → roster.css} +7 -11
  32. package/modules/hall-ui/public/roster.html +5 -5
  33. package/modules/hall-ui/public/roster.js +6 -7
  34. package/modules/hall-ui/public/roster.states.json +1 -1
  35. package/modules/hall-ui/public/sessions.html +1 -1
  36. package/modules/hall-ui/public/settings.html +2 -2
  37. package/modules/hall-ui/public/settings.js +2 -2
  38. package/modules/hall-ui/public/shell.js +3 -10
  39. package/modules/hall-ui/public/studio.html +1 -1
  40. package/modules/hall-ui/public/style.css +2 -9
  41. package/modules/hall-ui/public/task.html +1 -1
  42. package/modules/hall-ui/public/watch.html +1 -1
  43. package/modules/hall-ui/public/work.html +1 -1
  44. package/modules/hall-ui/records/builders-roster.md +1 -1
  45. package/modules/hall-ui/records/roster-and-ledger.md +2 -2
  46. package/modules/platform-identity/account-visibility.js +3 -3
  47. package/modules/platform-identity/platform-identity.js +2 -2
  48. package/modules/platform-identity/routes/profile.js +1 -1
  49. package/modules/platform-identity/routes/sso.js +4 -3
  50. package/modules/platform-identity/scouting-authz.js +2 -2
  51. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  52. package/package-lock.json +2 -2
  53. package/package.json +1 -1
  54. package/release-notes.json +6 -0
  55. package/src/bongos/auth.js +9 -24
  56. package/src/bongos/serve-internal.js +13 -19
  57. package/src/module-api.js +1 -1
  58. package/tests/account_privacy_flags.mjs +1 -1
  59. package/tests/auth_resolve_failure.mjs +6 -6
  60. package/tests/core_upgrade_owner_door.mjs +1 -1
  61. package/tests/edit_hints.mjs +8 -8
  62. package/tests/effective_visibility_predicate.mjs +1 -1
  63. package/tests/government_parity.mjs +1 -1
  64. package/tests/hall_builders_page_pass.mjs +2 -2
  65. package/tests/hall_nav.mjs +8 -8
  66. package/tests/hall_page_gate_map.mjs +14 -11
  67. package/tests/hall_roster_world.mjs +47 -78
  68. package/tests/module_contributions.mjs +0 -1
  69. package/tests/nav_permission_atoms.mjs +1 -1
  70. package/tests/{scouting_page_gate.mjs → page_gate_second_door.mjs} +34 -29
  71. package/tests/profile_rollup_consent.mjs +4 -4
  72. package/tests/profile_route.mjs +3 -26
  73. package/tests/scouting_owner_access_and_reach.mjs +4 -8
  74. package/modules/hall-ui/public/people.html +0 -79
  75. package/modules/hall-ui/public/people.js +0 -321
  76. package/modules/hall-ui/public/people.states.json +0 -89
  77. package/tests/people_directory_clip.mjs +0 -269
  78. package/tests/people_name_only.mjs +0 -263
@@ -5,10 +5,10 @@
5
5
  // in the open and the rest behind the kit's disclosure, the readiness is the
6
6
  // family's figure band in the same dot language as the roll line, the view
7
7
  // toggle is a view and not a filter, the accent lands on the one live door; the
8
- // legacy /scouting door is deleted and its URL redirects onto the one directory
9
- // page (task 1003440), which shows the avatar, the name and the login
10
- // the API returns; the session ledger drops the nowrap + wrapper pair that broke
11
- // its sticky header, makes every id a door, and names its floor from the pack.
8
+ // cross-project directory is gone from the hall — /scouting was deleted by task
9
+ // 1003440 and /people by task 1003444, and both URLs now land on /roster; the
10
+ // session ledger drops the nowrap + wrapper pair that broke its sticky header,
11
+ // makes every id a door, and names its floor from the pack.
12
12
  import test from 'node:test';
13
13
  import assert from 'node:assert/strict';
14
14
  import fs from 'node:fs';
@@ -156,8 +156,8 @@ test('goals: the card is the row folded — no box, one fact source, the subtitl
156
156
  assert.match(read('goals-page.js'), /gr-row gr-row--skel/, 'the boot skeleton is row-shaped');
157
157
  });
158
158
 
159
- test('goals.css and people.css spend only the fifteen, and every token they name exists', () => {
160
- for (const f of ['goals.css', 'people.css']) {
159
+ test('goals.css and roster.css spend only the fifteen, and every token they name exists', () => {
160
+ for (const f of ['goals.css', 'roster.css']) {
161
161
  const css = read(f);
162
162
  noAlias(css, f);
163
163
  tokensDefined(css, f);
@@ -165,89 +165,61 @@ test('goals.css and people.css spend only the fifteen, and every token they name
165
165
  });
166
166
 
167
167
  // =========================================================================
168
- // PEOPLE — one directory, one door
168
+ // PEOPLE — deleted from the hall; both old doors land on /roster
169
169
  // =========================================================================
170
170
 
171
- // Task 1003470 made /scouting render people.html byte-for-byte; task 1003440
172
- // removes the door itself. Two URLs onto one directory is a surface to keep in
173
- // sync that no reader ever asked for — but the URL still has to answer, or a
174
- // bookmark on the old door 404s.
175
- test('the legacy /scouting door is deleted, and nothing scouting-shaped is left in the hall', () => {
176
- for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css']) {
177
- assert.ok(!fs.existsSync(path.join(PUB, f)), f + ' is gone — one directory, one door');
171
+ // Task 1003440 deleted the legacy /scouting door; task 1003444 deleted /people
172
+ // itself (owner call, 2026-09-25 evening — ADR 0337 D5): cross-project discovery
173
+ // belongs to the hub, and the hall's directory is /roster. Both URLs still have
174
+ // to answer, or a bookmark 404s.
175
+ test('the /people page and its legacy /scouting door are deleted, and nothing of either is left in the hall', () => {
176
+ for (const f of ['scouting.html', 'scouting.js', 'scouting.states.json', 'scouting.css',
177
+ 'people.html', 'people.js', 'people.states.json', 'people.css']) {
178
+ assert.ok(!fs.existsSync(path.join(PUB, f)), f + ' is gone — the hall carries no cross-project directory');
178
179
  }
179
180
  });
180
181
 
181
- test('the /scouting URL redirects to /people, and no dead gate dispatches to it', () => {
182
+ test('the /people and /scouting URLs redirect to /roster, and no gate dispatches to either', () => {
182
183
  const serve = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
183
- const i = serve.indexOf('const SCOUTING_LEGACY_RE');
184
- assert.ok(i > 0, 'the retired URL keeps a matcher, so the bookmark still lands somewhere');
185
- const block = serve.slice(i, i + 1200);
186
- assert.ok(block.includes('res.redirect(301,'), 'and answers with a permanent redirect');
187
- assert.ok(block.includes("/people"), 'pointing at the directory that replaced it');
184
+ const decl = 'const RETIRED_DIRECTORY_RE = ';
185
+ const i = serve.indexOf(decl);
186
+ assert.ok(i > 0, 'the retired URLs keep a matcher, so a bookmark still lands somewhere');
187
+ const block = serve.slice(i, i + 900);
188
+ // 302, not 301: /roster is the interim target until the hub community page
189
+ // (task 1003363) ships, and a browser caches a 301 past any re-point.
190
+ assert.ok(block.includes('res.redirect(302,'), 'and answers with a temporary redirect');
191
+ assert.ok(block.includes('/roster'), 'pointing at the in-project directory that remains');
188
192
  // RUN the shipped matcher, rather than trusting that it reads right. Anchoring
189
193
  // is the half that goes wrong: an unanchored twin would swallow the API route.
190
- const decl = 'const SCOUTING_LEGACY_RE = ';
191
- const j = serve.indexOf(decl);
192
- const rx = eval(serve.slice(j + decl.length, serve.indexOf(';', j))); // eslint-disable-line no-eval
193
- for (const p of ['/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
194
+ const rx = eval(serve.slice(i + decl.length, serve.indexOf(';', i))); // eslint-disable-line no-eval
195
+ for (const p of ['/builders/people', '/builders/people/', '/builders/people.html',
196
+ '/builders/scouting', '/builders/scouting/', '/builders/scouting.html']) {
194
197
  assert.ok(rx.test(p), p + ' must reach the redirect');
195
198
  }
196
- for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/people',
197
- '/builders/scoutingfoo', '/builders/scouting/extra']) {
198
- assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the directory are not the door');
199
+ for (const p of ['/api/gds/scouting', '/api/bongos/scouting', '/builders/roster',
200
+ '/builders/peoplefoo', '/builders/people/extra', '/builders/scoutingfoo', '/builders/scouting/extra']) {
201
+ assert.ok(!rx.test(p), p + ' must NOT be redirected — the data route and the roster are not the door');
199
202
  }
200
203
  // The gate is the half that would rot silently: a page-gate branch still
201
204
  // naming a deleted page is a wall guarding nothing.
202
- assert.ok(!decomment(serve).includes('SCOUTING_PAGE_RE'),
203
- 'no page gate is left dispatching to a page that no longer exists');
204
- });
205
-
206
- test('the directory page loads its own script, sheet and toolkit', () => {
207
- const people = read('people.html');
208
- for (const html of [people]) {
209
- assert.match(html, /\/builders\/people\.js/);
210
- assert.match(html, /\/builders\/people\.css/);
211
- assert.match(html, /\/builders\/dom-utils\.js/, 'the toolkit the rank label and the number formats come from');
212
- assert.match(html, /<h1 class="page-head__h">People<\/h1>/);
205
+ const code = decomment(serve);
206
+ for (const gone of ['SCOUTING_PAGE_RE', 'PEOPLE_PAGE_RE', 'SCOUTING_LEGACY_RE', 'requireProjectCuratePage']) {
207
+ assert.ok(!code.includes(gone), `${gone} is gone — no gate dispatches to a page that no longer exists`);
213
208
  }
214
209
  });
215
210
 
216
- test('the directory row shows the avatar, the name and the login the API returns, and the name is a door', () => {
217
- const src = read('people.js');
218
- assert.match(src, /b\.avatar_url\s*\?\s*'<img class="dir-avatar" src="' \+ esc\(b\.avatar_url\)/, 'the avatar the pages dropped');
219
- assert.match(src, /dir-avatar dir-avatar--initial/, 'an initial when there is none');
220
- assert.match(src, /return b\.display_name \|\| b\.github_login \|\| \('#' \+ b\.github_id\);/, 'the display name reads first');
221
- assert.match(src, /class="lb-name dir-name" href="https:\/\/github\.com\/' \+ encodeURIComponent\(b\.github_login\)/,
222
- 'the shell\'s .lb-name, to the one page every listed builder has');
223
- assert.match(src, /'<span class="dir-login">@' \+ esc\(b\.github_login\)/, 'the login under the name says where the door goes');
224
- // The chip is the assertion; what goes IN it is the role vocabulary, not the column
225
- // (task 1004040). This line used to pin `esc(String(d))` — the raw value — so it read as
226
- // a guard on the chip while actually holding the Directory to rendering 'unclassified'
227
- // and a lowercase 'ui'. Pinning the wrapper and the helper keeps the original intent
228
- // without pinning the defect that happened to be inside it.
229
- assert.match(src, /<span class="fact-chip">' \+ esc\(roleLabel\(d\)\)/, 'roles are chips, spoken not printed');
230
- assert.doesNotMatch(src, /class="num"/, 'the shell\'s ledger__num, not the bare .num scouting.js had corrected');
231
- assert.match(src, /class="ledger__num"/);
232
- });
233
-
234
- test('the directory has a search, a sort and a craft lens on the kit bar, and pages through the kit', () => {
235
- const src = read('people.js');
236
- assert.match(src, /kit\.makeFilterBar\(\{/);
237
- assert.match(src, /search: \{ id: 'people-q'/);
238
- assert.match(src, /\{ id: 'people-sort', label: 'Sort'/);
239
- // task 1003936: the axis label is read from OTB.roleAxisLabel(), never typed.
240
- assert.match(src, /\{ id: 'people-craft', label: window\.OTB\.roleAxisLabel\(\)/);
241
- assert.match(src, /kit\.makePager\('people'/);
242
- assert.match(src, /kit\.makeLedger\(\{/);
243
- assert.doesNotMatch(src, /createElement\('table'\)/, 'no hand-built table');
244
- });
245
-
246
- test('the directory names its floor from the pack, and never says Archon-only', () => {
247
- const src = read('people.js');
248
- assert.match(src, /rankLabel\('metic'\)/);
249
- assert.doesNotMatch(decomment(src), /Archon-only/);
250
- assert.doesNotMatch(read('people.html'), /Archon/);
211
+ test('no hall page links to /people or loads its assets', () => {
212
+ const offenders = [];
213
+ for (const f of fs.readdirSync(PUB).filter((n) => /\.(html|js|css)$/.test(n))) {
214
+ const src = decomment(read(f)).replace(/<!--[\s\S]*?-->/g, '');
215
+ if (/['"(=]\/(builders\/)?people(\.html)?\/?['"?#)\s>]/.test(src)) offenders.push(`${f}: a /people link`);
216
+ if (/people\.(css|js)\b/.test(src)) offenders.push(`${f}: a people.css/people.js reference`);
217
+ }
218
+ assert.deepEqual(offenders, [], 'a link to the deleted page would bounce off the redirect for no reason');
219
+ // The roster's identity cell moved with it: the sheet is the roster's own now.
220
+ const roster = read('roster.html');
221
+ assert.match(roster, /\/builders\/roster\.css/, 'roster.html loads its own sheet');
222
+ assert.doesNotMatch(roster, /cross-project directory/, 'and no longer contrasts itself with a page the hall no longer has');
251
223
  });
252
224
 
253
225
  // =========================================================================
@@ -304,7 +276,7 @@ test('sessions: the floor is written from the pack\'s rank label, never a hardco
304
276
  // The harness carries what the pages read, and the states files ask for the new states
305
277
  // =========================================================================
306
278
 
307
- test('the harness feeds: rollups for every goal, the category set, avatars on the directory', () => {
279
+ test('the harness feeds: rollups for every goal and the category set', () => {
308
280
  const goals = readJson('goals.json').goals;
309
281
  const rollups = readJson('goals__rollups.json').rollups;
310
282
  const byGoal = new Set(rollups.map((r) => String(r.goal_id)));
@@ -315,16 +287,13 @@ test('the harness feeds: rollups for every goal, the category set, avatars on th
315
287
  assert.ok(goals.some((g) => g.category && g.category.slug), 'some goals carry a category');
316
288
  const cats = readJson('work-categories.json').categories;
317
289
  assert.ok(cats.length >= 2 && cats.every((c) => c.slug && c.label));
318
- const dir = readJson('scouting.json').builders;
319
- assert.ok(dir.some((b) => b.avatar_url) && dir.some((b) => !b.avatar_url), 'the directory fixture has builders with and without an avatar');
290
+ assert.ok(!fs.existsSync(path.join(FIX, 'scouting.json')), 'the directory fixture left with the only page that read it');
320
291
  });
321
292
 
322
293
  test('the states files exercise the redesign\'s new states', () => {
323
294
  const goals = JSON.parse(read('goals.states.json'));
324
295
  assert.ok(goals.states.filters && goals.states.cards, 'goals: the Filters disclosure and the cards view are measured');
325
296
  assert.deepEqual(goals.states.cards.expect.value, [['#f-goal-view', 'cards']]);
326
- const people = JSON.parse(read('people.states.json'));
327
- assert.ok(people.states.search, 'people: a search is measured');
328
297
  const sessions = JSON.parse(read('sessions.states.json'));
329
298
  assert.deepEqual(sessions.states.ledger.expect.visible, ['#stats-scroll', '#sessions-scroll']);
330
299
  });
@@ -227,7 +227,6 @@ test('the role axis has exactly one user-facing word, and no surface types it',
227
227
  const SURFACES = [
228
228
  'modules/hall-ui/public/work.js',
229
229
  'modules/hall-ui/public/goals-page.js',
230
- 'modules/hall-ui/public/people.js',
231
230
  'modules/hall-ui/public/profile.js',
232
231
  'modules/hall-ui/public/builders.js',
233
232
  ];
@@ -87,7 +87,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
87
87
  // the file and left the query alone, so a returning browser kept the cached copy —
88
88
  // a fix that reaches nobody looks identical to a fix that does not work.
89
89
 
90
- const SHELL_SHA = 'ff2017aa9bbbc507';
90
+ const SHELL_SHA = '6b28297270d1e1f0';
91
91
 
92
92
  test('shell.js has not changed without its cache-bust being bumped', () => {
93
93
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -1,12 +1,16 @@
1
- // tests/scouting_page_gate.mjs
1
+ // tests/page_gate_second_door.mjs
2
2
  //
3
- // The OTHER half of R14 (task 1002290, ADR 0210 D3): the `/builders/people`
4
- // SHELL must admit exactly the two audiences `GET /scouting` admits.
3
+ // The page gates' `alsoAdmits` SECOND DOOR (ADR 0210), proven on the one gate that
4
+ // declares it: requireCorePinPage, the /deploy shell, whose routes admit a hosted
5
+ // project's own owner beside `core.pin.move` (ADR 0339, task 1004174). The door's
6
+ // first user was the hall's /people page; task 1003444 deleted that page (ADR 0337
7
+ // D5) and retargeted this file (formerly tests/scouting_page_gate.mjs) here.
5
8
  //
6
- // WHY THIS IS ITS OWN FILE, AND ITS OWN GATE. `runPageGate` grew an optional
9
+ // WHY THIS IS ITS OWN FILE. `runPageGate` grew an optional
7
10
  // `alsoAdmits` door — new authorization code that runs on EVERY page-gate
8
11
  // denial, for every gate. The route proof
9
- // (tests/scouting_owner_access_and_reach.mjs) cannot reach it: page gates are
12
+ // (tests/core_upgrade_owner_door.mjs) proves the widening itself; this file pins
13
+ // the door's failure modes, which a route proof cannot reach: page gates are
10
14
  // driven by a session, and that file's ESM imports pull `auth.js` in through the
11
15
  // module graph before any session double could be installed. This file installs
12
16
  // the double FIRST, exactly as tests/government_page_gates.mjs does, and so gets
@@ -21,12 +25,12 @@
21
25
  // 3. every way the second door can fail — module absent, function absent, a
22
26
  // throw, a non-boolean, and a name that resolves only through the prototype
23
27
  // — fails CLOSED. A fail-open default in an authz path is the bug class;
24
- // 4. the other four gates are untouched, and their seam is never even
28
+ // 4. the other gates are untouched, and their seam is never even
25
29
  // consulted, because they declare no second door.
26
30
  //
27
31
  // DB-free: a session double and a port double. No Postgres, no HTTP.
28
32
  //
29
- // Run: node tests/scouting_page_gate.mjs
33
+ // Run: node tests/page_gate_second_door.mjs
30
34
 
31
35
  import { strict as assert } from 'node:assert';
32
36
  import { createRequire } from 'node:module';
@@ -53,12 +57,13 @@ async function t(name, fn) {
53
57
  }
54
58
 
55
59
  // The cast. `rank` is the caller's rank ON THE HUB; `github_id` is the account
56
- // whose project memberships the ownership door asks about. The whole point of
57
- // R14 is the first row: a project's registrant is an Archon on their OWN
58
- // instance and, here, very often a xenos holding nothing at all.
60
+ // whose project memberships the ownership door asks about. The first row is the
61
+ // point: a project's registrant is an Archon on their OWN instance and, on the
62
+ // hub, very often a xenos holding nothing at all.
59
63
  const OWNER = { builder_id: 1, github_id: '9001', rank: 'xenos' };
60
64
  const STRANGER = { builder_id: 2, github_id: '9002', rank: 'xenos' };
61
- const OPERATOR = { builder_id: 5, github_id: '9005', rank: 'metic' };
65
+ // core.pin.move is archon-floor (ADR 0293), so the permission-door control is an Archon.
66
+ const OPERATOR = { builder_id: 5, github_id: '9005', rank: 'archon' };
62
67
 
63
68
  // The ownership answer, as the platform-identity.hub port supplies it.
64
69
  const OWNS = new Set(['9001']);
@@ -94,33 +99,33 @@ async function runGate(gate, session, hub) {
94
99
  return { res, nexted };
95
100
  }
96
101
 
97
- const DENIED_CURATE = { code: 302, loc: '/builders?denied=project.curate' };
102
+ const DENIED_PIN = { code: 302, loc: '/builders?denied=core.pin.move' };
98
103
 
99
- console.log('the /people shell follows its data route:');
104
+ console.log('the /deploy shell follows its data routes:');
100
105
 
101
106
  await t('a PROJECT OWNER reaches the shell, holding no permission at all', async () => {
102
- const { nexted } = await runGate('requireProjectCuratePage', OWNER, workingHub);
107
+ const { nexted } = await runGate('requireCorePinPage', OWNER, workingHub);
103
108
  assert.ok(nexted,
104
109
  'the page gate must admit the audience its data route admits — otherwise the widening is real in the API and unreachable in the product');
105
110
  });
106
111
 
107
112
  await t('CONTROL: the permission door still opens the shell on its own', async () => {
108
- const { nexted } = await runGate('requireProjectCuratePage', OPERATOR, workingHub);
109
- assert.ok(nexted, 'ADR 0157 put project.curate at the metic floor; R14 did not move it');
113
+ const { nexted } = await runGate('requireCorePinPage', OPERATOR, workingHub);
114
+ assert.ok(nexted, 'core.pin.move admits an Archon (ADR 0293); ADR 0339 did not move it');
110
115
  // ...and it does not need the second door at all.
111
- const { nexted: noHub } = await runGate('requireProjectCuratePage', OPERATOR, null);
116
+ const { nexted: noHub } = await runGate('requireCorePinPage', OPERATOR, null);
112
117
  assert.ok(noHub, 'an operator gets in on a vanilla instance with no hub module');
113
118
  });
114
119
 
115
120
  await t('a builder who is neither gets the byte-identical redirect the gate always sent', async () => {
116
- const { res, nexted } = await runGate('requireProjectCuratePage', STRANGER, workingHub);
117
- assert.equal(nexted, false, 'building on a project is not owning one');
118
- assert.deepEqual(res._redirect, DENIED_CURATE,
121
+ const { res, nexted } = await runGate('requireCorePinPage', STRANGER, workingHub);
122
+ assert.equal(nexted, false, 'a xenos who owns no project stays out');
123
+ assert.deepEqual(res._redirect, DENIED_PIN,
119
124
  'the denial keeps naming the permission (task 1002613), so the hall shell can still render WHY');
120
125
  });
121
126
 
122
127
  await t('an unauthenticated visitor is still bounced into sign-in, never to the second door', async () => {
123
- const { res, nexted } = await runGate('requireProjectCuratePage', null, workingHub);
128
+ const { res, nexted } = await runGate('requireCorePinPage', null, workingHub);
124
129
  assert.equal(nexted, false);
125
130
  assert.match(res._redirect.loc, /auth\/web\/start/, 'no session means sign-in, not a denial page');
126
131
  });
@@ -139,12 +144,12 @@ await t('every way the second door can fail, it fails CLOSED', async () => {
139
144
  ['the function resolves only through the prototype', Object.create({ isProjectOwner: () => true })],
140
145
  ];
141
146
  for (const [why, hub] of cases) {
142
- const { res, nexted } = await runGate('requireProjectCuratePage', OWNER, hub);
147
+ const { res, nexted } = await runGate('requireCorePinPage', OWNER, hub);
143
148
  assert.equal(nexted, false, `${why}: must not admit`);
144
- assert.deepEqual(res._redirect, DENIED_CURATE,
149
+ assert.deepEqual(res._redirect, DENIED_PIN,
145
150
  `${why}: must still redirect rather than hang, throw, or answer JSON`);
146
151
  }
147
- const { nexted } = await runGate('requireProjectCuratePage', OWNER, workingHub);
152
+ const { nexted } = await runGate('requireCorePinPage', OWNER, workingHub);
148
153
  assert.ok(nexted, 'CONTROL: the same caller IS admitted when the door works — the six refusals are the door, not the caller');
149
154
  });
150
155
 
@@ -152,7 +157,7 @@ await t('the second door is consulted only where the AUTHORITY answer was no', a
152
157
  // A permission holder must not pay for it, and — more important — the seam
153
158
  // must never be able to NARROW a caller the permission already admitted.
154
159
  const spy = { isProjectOwner: () => { throw new Error('the seam must not be consulted for a caller the permission already admitted'); } };
155
- const { nexted } = await runGate('requireProjectCuratePage', OPERATOR, spy);
160
+ const { nexted } = await runGate('requireCorePinPage', OPERATOR, spy);
156
161
  assert.ok(nexted, 'the atom holder is admitted before the second door is reached');
157
162
  });
158
163
 
@@ -176,14 +181,14 @@ await t('the OTHER page gates are untouched — their seam is never consulted at
176
181
 
177
182
  await t('only the gates WITH A PROOF declare a second door (a widening must be opted into, one gate at a time)', () => {
178
183
  const src = require('node:fs').readFileSync(new URL('../src/bongos/auth.js', import.meta.url), 'utf8');
179
- // Keyed by GATE, not just the door's name, so a third gate borrowing 'isProjectOwner'
184
+ // Keyed by GATE, not just the door's name, so a second gate borrowing 'isProjectOwner'
180
185
  // fails here instead of hiding behind an unchanged list of names. Each entry names the
181
186
  // file that proves its widening:
182
- // requireProjectCuratePage — this file (ADR 0210 D3);
183
- // requireCorePinPage — tests/core_upgrade_owner_door.mjs (ADR 0339, task 1004174).
187
+ // requireCorePinPage — tests/core_upgrade_owner_door.mjs (ADR 0339, task 1004174).
188
+ // (requireProjectCuratePage, the /people gate, was deleted with its page — task 1003444.)
184
189
  const declarations = [...src.matchAll(/(require\w+Page):\s*Object\.freeze\(\{[^}]*alsoAdmits:\s*'([^']+)'/g)]
185
190
  .map((m) => `${m[1]}:${m[2]}`);
186
- assert.deepEqual(declarations, ['requireProjectCuratePage:isProjectOwner', 'requireCorePinPage:isProjectOwner'],
191
+ assert.deepEqual(declarations, ['requireCorePinPage:isProjectOwner'],
187
192
  'a second alsoAdmits appearing without its own proof is how a page gate quietly widens');
188
193
  const plain = [...src.matchAll(/alsoAdmits:\s*'([^']+)'/g)].length;
189
194
  assert.equal(plain, declarations.length, 'every alsoAdmits sits on a named page gate this test can see');
@@ -4,7 +4,7 @@
4
4
  // unnest(), so the rollup queries need a live Postgres (the task-1002911
5
5
  // precedent). Replays the module's actual migrations, then asserts:
6
6
  // 1. listScoutingRollup lists ONLY consented + not-hidden builders (D2/D3) —
7
- // provisional, hidden and never-accepted accounts drop out of /people +
7
+ // provisional, hidden and never-accepted accounts drop out of
8
8
  // /scouting by construction. Consent is TWO columns since ADR 0213
9
9
  // (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
10
10
  // LEGACY fixture is the pre-terms row that proves the second one.
@@ -151,7 +151,7 @@ try {
151
151
  [...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
152
152
  );
153
153
 
154
- // 1 — the scouting/people predicate (D2/D3).
154
+ // 1 — the scouting predicate (D2/D3).
155
155
  const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
156
156
  ok(listed.includes(ACTIVE), 'active + accepted + visible builder is listed');
157
157
  ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
@@ -209,11 +209,11 @@ try {
209
209
 
210
210
  // 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
211
211
  // effect IMMEDIATELY on every public read — the profile port and the
212
- // /people + /scouting rollup — while the self view keeps serving.
212
+ // /scouting rollup — while the self view keeps serving.
213
213
  await pi.setAccountHideStats({ githubId: ACTIVE, githubLogin: 'active-b', hide: true }, { pool });
214
214
  ok(await pi.getPublicProfileExtras(ACTIVE, { pool }) === null, 'hide ON → public read drops to null immediately');
215
215
  const hiddenListed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
216
- ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /people + /scouting immediately');
216
+ ok(!hiddenListed.includes(ACTIVE), 'hide ON → the builder drops out of /scouting immediately');
217
217
  const stillOwn = await pi.getOwnProfileExtras(ACTIVE, { pool });
218
218
  ok(stillOwn && stillOwn.totals.credits === 175, 'hide ON → the self view still serves their own numbers');
219
219
  let hiddenAcct = await pi.getAccount(ACTIVE, { pool });
@@ -18,7 +18,7 @@
18
18
  // 2. flipping hide_stats removes it on the IMMEDIATE next load, and the
19
19
  // response is `Cache-Control: no-store` — the old `public, max-age=60`
20
20
  // would keep serving the pre-hide body for a minute (ADR 0171 D5);
21
- // 3. `/scouting` (the endpoint the hall's /people page renders) drops the
21
+ // 3. `/scouting` (the recruiting directory's endpoint) drops the
22
22
  // hidden builder too — enforcement is on every read path, not the profile;
23
23
  // 4. the leak guard BITES: planting a private field turns the key-list and
24
24
  // sentinel assertions red (a leak test that cannot fail is not a test);
@@ -35,14 +35,9 @@
35
35
  import { strict as assert } from 'node:assert';
36
36
  import { createRequire } from 'node:module';
37
37
  import http from 'node:http';
38
- import { readFileSync } from 'node:fs';
39
- import { fileURLToPath } from 'node:url';
40
- import { dirname, join } from 'node:path';
41
38
 
42
39
  const require = createRequire(import.meta.url);
43
40
  const express = require('express');
44
- const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
45
- const sourceOf = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
46
41
 
47
42
  let passed = 0;
48
43
  let failed = 0;
@@ -410,8 +405,8 @@ const engine = {
410
405
  const sql = String(text);
411
406
  engine.calls.push({ sql, params });
412
407
 
413
- // listScoutingRollup — the ONE read behind GET /scouting and the hall's
414
- // /people page. Honors exactly the predicates the query declares.
408
+ // listScoutingRollup — the ONE read behind GET /scouting. Honors exactly
409
+ // the predicates the query declares.
415
410
  if (/AS project_count/.test(sql)) {
416
411
  let rows = SCOUTING_TABLE;
417
412
  if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
@@ -666,24 +661,6 @@ await t('R06.3 GET /scouting ranks the visible builder, lists the HIDDEN one by
666
661
  }
667
662
  });
668
663
 
669
- await t('R06.3 the /people page reads /scouting and nothing else', async () => {
670
- // /people is the hall's render of the same endpoint, so whatever /scouting
671
- // drops, /people drops. Pin that it has no second data source.
672
- //
673
- // MATCHED AS AN EXPRESSION, NOT A STRING LITERAL. This used to capture the
674
- // whole URL out of `fetch('…')` and compare it to one hardcoded string, which
675
- // pinned the row limit as a side effect of pinning the ENDPOINT — so task
676
- // 1003110 naming that limit (`DIRECTORY_LIMIT`, the one constant that feeds
677
- // both the request and the "showing the first N" copy) failed a test about
678
- // data sources. What is load-bearing here is the count and the destination:
679
- // ONE read, and it goes to the scouting rollup. How the query string is
680
- // assembled is the page's business.
681
- const args = [...sourceOf('modules/hall-ui/public/people.js').matchAll(/\bfetch\(\s*([^,)]+)/g)].map((m) => m[1].trim());
682
- assert.equal(args.length, 1, '/people has exactly one data source');
683
- assert.match(args[0], /^'\/api\/bongos\/scouting\?limit='/,
684
- 'and it is the scouting rollup — nothing may re-enter through a second endpoint');
685
- });
686
-
687
664
  scout.server.close();
688
665
  server.close();
689
666
 
@@ -12,9 +12,8 @@
12
12
  // WHO APPEARS IN IT. The recruiting opt-out (`recruiter_discoverable`, privacy
13
13
  // spec D1) gated nothing until this task: a public account that had opted out
14
14
  // still appeared. listScoutingRollup now joins scoutingListableSql, so an
15
- // opted-out builder is absent from /scouting AND from the hall's /people —
16
- // which is the SAME endpoint, proved from source below rather than asserted
17
- // twice — and flipping the toggle back restores them.
15
+ // opted-out builder is absent from /scouting — its one reader's predicate is
16
+ // proved from source below — and flipping the toggle back restores them.
18
17
  //
19
18
  // AND NEITHER AXIS TOUCHES THE OTHER (ADR 0016). Being scoutable is not
20
19
  // authority and opting out is not a punishment: an opted-out builder who owns
@@ -641,13 +640,10 @@ await t('CALIBRATION: the interpreter refuses what it cannot model, rather than
641
640
  ));
642
641
  });
643
642
 
644
- await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
645
- const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
646
- assert.match(page, /fetch\('\/api\/bongos\/scouting/,
647
- 'the hall directory reads GET /scouting; if it ever grows its own query, this proof stops covering it');
643
+ await t('listScoutingRollup, the one reader behind GET /scouting, joins the composed predicate', async () => {
648
644
  const source = fs.readFileSync(path.join(repo, 'modules/platform-identity/platform-identity.js'), 'utf8');
649
645
  assert.match(source, /scoutingListableSql\('pa'\)/,
650
- 'and listScoutingRollup — the one reader behind both surfaces — joins the composed predicate');
646
+ 'the reader joins scoutingListableSql, so "absent from /scouting" holds by construction');
651
647
  });
652
648
 
653
649
  // ===========================================================================
@@ -1,79 +0,0 @@
1
- <!doctype html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="utf-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1">
6
- <title>People — {{worldName}}</title>
7
- <meta name="description" content="The builder directory: everyone building across {{worldName}} projects, searchable and ranked by track record.">
8
- <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
- <link rel="preconnect" href="https://fonts.googleapis.com">
10
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
11
- <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
12
- <!-- task 1003470: the identity cell (avatar, name, login) is the one thing the
13
- directory adds to the shell's ledger. -->
14
- <link rel="stylesheet" href="/builders/people.css">
15
- </head>
16
- <body data-page="people" data-page-title="People">
17
-
18
- <div class="app">
19
- <aside class="app-sidebar" id="app-sidebar" aria-label="Primary"></aside>
20
- <div class="app-scrim" id="app-scrim" hidden></div>
21
- <div class="app-body">
22
- <header class="app-topbar" id="app-topbar"></header>
23
- <div class="app-content">
24
- <!-- The board page head (task 1003307): the label voice, because on a
25
- working surface the page's own name is the least interesting thing on
26
- it. The sub is rewritten by people.js once the directory has loaded. -->
27
- <div class="page-head page-head--board">
28
- <h1 class="page-head__h">People</h1>
29
- <p class="page-head__sub" id="people-sub">Everyone building across hosted projects, ranked by track record. Descriptive only — cross-project numbers never carry authority here.</p>
30
- </div>
31
-
32
- <main id="hall-main">
33
- <!-- task 1003470: ONE directory. Its legacy door /scouting rendered this
34
- same page from the same script, and task 1003440 deleted it — the same
35
- /api/bongos/scouting rollup used to be drawn three different ways. -->
36
- <section class="scroll" id="people-scroll" aria-labelledby="people-h">
37
- <h2 id="people-h" class="scroll__h">Builder directory<span class="count-pill" id="people-count" hidden></span></h2>
38
- <div id="people-filters"></div>
39
- <div id="people-body">
40
- <div class="profile__placeholder">Loading the directory…</div>
41
- </div>
42
- <!-- task 1003110: the clipped-directory footnote. Hidden unless the
43
- response came back FULL, which is the only truncation signal the
44
- rollup offers; people.js writes it. -->
45
- <p class="ledger__note" id="people-foot" hidden></p>
46
- </section>
47
-
48
- <!-- task 1002291 (ADR 0334): builders who hid their cross-project record
49
- but are still open to recruiting, listed by name alone (privacy spec
50
- D2). Its own panel because they are never ranked; hidden unless the
51
- response carries a row, and people.js writes it. -->
52
- <section class="scroll" id="people-named-scroll" aria-labelledby="people-named-h" hidden>
53
- <h2 id="people-named-h" class="scroll__h">Listed by name only<span class="count-pill" id="people-named-count" hidden></span></h2>
54
- <p class="scroll__lede" id="people-named-lede">These builders are open to recruiting but keep their cross-project record hidden, so only who they are is shown. They are never ranked, and a role filter never finds them.</p>
55
- <div id="people-named-body"></div>
56
- <p class="ledger__note" id="people-named-foot" hidden></p>
57
- </section>
58
- </main>
59
-
60
- <footer class="app-foot" id="app-foot"></footer>
61
- </div>
62
- </div>
63
- </div>
64
-
65
- <div id="toast" class="toast" role="status" aria-live="polite" data-visible="0"></div>
66
-
67
- <!-- The app shell (sidebar + top bar). Dependency-free; loaded FIRST so the
68
- chrome exists before the page script runs. -->
69
- <script src="/builders/shell.js?v=2026-09-26-swupdate"></script>
70
- <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
71
- <script src="/builders/palette.js" defer></script>
72
- <!-- shared frontend toolkit (window.OTB — rank labels, number formats) + the
73
- branding pack's currency label, loaded BEFORE people.js. -->
74
- <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
75
- <script src="/builders/brand-apply.js"></script>
76
- <script src="/builders/hall-kit.js"></script>
77
- <script src="/builders/people.js"></script>
78
- </body>
79
- </html>