@bongos/core 1.19.1016 → 1.19.1017

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 (39) hide show
  1. package/.bongos-core.json +78 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +56 -5
  10. package/docs/api-reference.md +7 -1
  11. package/docs/copy-inventory.md +5 -3
  12. package/docs/copy-registry.json +22 -4
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +2 -0
  15. package/modules/hall-ui/public/people.html +11 -0
  16. package/modules/hall-ui/public/people.js +77 -11
  17. package/modules/hall-ui/public/people.states.json +1 -1
  18. package/modules/platform-identity/account-visibility.js +33 -2
  19. package/modules/platform-identity/builder-search.js +153 -0
  20. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  21. package/modules/platform-identity/module.json +1 -1
  22. package/modules/platform-identity/routes/community-search.js +120 -0
  23. package/modules/platform-identity/routes/scouting.js +14 -7
  24. package/modules/platform-identity/scouting-name-only.js +47 -0
  25. package/modules/public-landing/public/projects-floor.states.json +9 -2
  26. package/modules/public-landing/public/projects.html +69 -4
  27. package/modules/ui-design/styles/expedition/mock.html +16 -3
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/account_privacy_flags.mjs +4 -1
  32. package/tests/card_apply_ui.mjs +297 -0
  33. package/tests/community_search_route.mjs +385 -0
  34. package/tests/effective_visibility_predicate.mjs +49 -1
  35. package/tests/people_name_only.mjs +262 -0
  36. package/tests/profile_route.mjs +23 -5
  37. package/tests/recruiter_sliver_boundary.mjs +3 -0
  38. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  39. package/tests/ui_design_styles.mjs +13 -0
@@ -0,0 +1,385 @@
1
+ // tests/community_search_route.mjs
2
+ //
3
+ // Guards GET /community/search — the PUBLIC builder search (D4,
4
+ // docs/specs/bongos-v1-community-tab.md; task 1002979). DB-free: a fake pool
5
+ // asserts the REAL query text literally contains the shared predicates
6
+ // (accountVisibleSql('pa') / rollupVisibleSql('pa2'), read from
7
+ // account-visibility.js — never re-implemented), so deleting either predicate
8
+ // from builder-search.js fails THIS test, not just the separate structural
9
+ // guard. The fake's own row-matching (which rows pass) is then a fixture
10
+ // simulation layered on top, applying the same JS-twin predicates to decide
11
+ // what the query WOULD return — it is not, on its own, proof of the SQL, and
12
+ // isn't presented as such; the predicate-presence assertions above are what
13
+ // actually pins the SQL.
14
+ //
15
+ // What this pins, in order:
16
+ // 1. the pure helpers (clampLimit/clampOffset/escapeLikePrefix) — no DB.
17
+ // 2. the D4 matrix, end to end through the real router + real domain fn:
18
+ // - a hide_stats account is findable BY NAME (name-only search).
19
+ // - the SAME account drops out of a DISCIPLINE-filtered search (a
20
+ // discipline is rollup-derived, and hide_stats suppresses the rollup).
21
+ // - private and provisional accounts never appear, filtered or not.
22
+ // - a plain active+public account is findable by name and by its real
23
+ // discipline, and absent under a discipline it does not hold.
24
+ // 3. input validation (400 on missing/too-short/oversized q, oversized
25
+ // discipline), the 500 path, the rate limiter, and the response shape
26
+ // (github_id never crosses, MAX_OFFSET clamp echoed in `page`).
27
+ //
28
+ // Run: node tests/community_search_route.mjs
29
+
30
+ import { strict as assert } from 'node:assert';
31
+ import { createRequire } from 'node:module';
32
+ import http from 'node:http';
33
+
34
+ const require = createRequire(import.meta.url);
35
+ const express = require('express');
36
+
37
+ let passed = 0;
38
+ let failed = 0;
39
+ async function t(name, fn) {
40
+ try { await fn(); passed++; console.log(` ok ${name}`); }
41
+ catch (e) { failed++; console.error(` FAIL ${name}\n ${e.message}`); }
42
+ }
43
+
44
+ // ---- pure helpers ----------------------------------------------------------
45
+
46
+ const builderSearch = require('../modules/platform-identity/builder-search.js');
47
+
48
+ await t('clampLimit: default, clamp to MAX_LIMIT, ignore garbage', () => {
49
+ assert.equal(builderSearch.clampLimit(undefined), builderSearch.DEFAULT_LIMIT);
50
+ assert.equal(builderSearch.clampLimit('not a number'), builderSearch.DEFAULT_LIMIT);
51
+ assert.equal(builderSearch.clampLimit(0), builderSearch.DEFAULT_LIMIT);
52
+ assert.equal(builderSearch.clampLimit(-5), builderSearch.DEFAULT_LIMIT);
53
+ assert.equal(builderSearch.clampLimit(5), 5);
54
+ assert.equal(builderSearch.clampLimit(9999), builderSearch.MAX_LIMIT);
55
+ });
56
+
57
+ await t('clampOffset: default 0, ignore negatives/garbage', () => {
58
+ assert.equal(builderSearch.clampOffset(undefined), 0);
59
+ assert.equal(builderSearch.clampOffset(-1), 0);
60
+ assert.equal(builderSearch.clampOffset('nope'), 0);
61
+ assert.equal(builderSearch.clampOffset(40), 40);
62
+ });
63
+
64
+ await t('escapeLikePrefix: escapes %, _ and backslash so a query cannot widen its own prefix', () => {
65
+ assert.equal(builderSearch.escapeLikePrefix('50%'), '50\\%');
66
+ assert.equal(builderSearch.escapeLikePrefix('a_b'), 'a\\_b');
67
+ assert.equal(builderSearch.escapeLikePrefix('a\\b'), 'a\\\\b');
68
+ assert.equal(builderSearch.escapeLikePrefix('plain'), 'plain');
69
+ });
70
+
71
+ await t('clampOffset: capped at MAX_OFFSET (deep pagination on a public route is a free scan-and-discard knob)', () => {
72
+ assert.equal(builderSearch.clampOffset(builderSearch.MAX_OFFSET), builderSearch.MAX_OFFSET);
73
+ assert.equal(builderSearch.clampOffset(builderSearch.MAX_OFFSET + 1), builderSearch.MAX_OFFSET);
74
+ assert.equal(builderSearch.clampOffset(999999999), builderSearch.MAX_OFFSET);
75
+ });
76
+
77
+ await t('the fuzzy trigram leg case-folds BOTH sides (a bug class: pg_trgm % is case-sensitive, unlike ILIKE)', () => {
78
+ // Static, source-level pin — the fake pool below cannot reproduce Postgres's
79
+ // real case sensitivity, so this is the guard that actually catches a
80
+ // regression to the raw (unlowered) form the bug report named.
81
+ const fs = require('fs');
82
+ const src = fs.readFileSync(require.resolve('../modules/platform-identity/builder-search.js'), 'utf8');
83
+ assert.match(src, /lower\(pa\.handle\) % lower\(\$1\)/, 'the handle fuzzy leg must compare lower() on both sides');
84
+ assert.match(src, /lower\(pa\.display_name\) % lower\(\$1\)/, 'the display_name fuzzy leg must compare lower() on both sides');
85
+ assert.ok(!src.includes('pa.handle % $1'), 'the raw (unlowered) form must not reappear');
86
+ assert.ok(!src.includes('pa.display_name % $1'), 'the raw (unlowered) form must not reappear');
87
+ });
88
+
89
+ // ---- fake engine ------------------------------------------------------------
90
+ //
91
+ // Reads the SAME predicates the module reads — imported from
92
+ // account-visibility.js, never restated — so this test cannot pass by
93
+ // agreeing with a hand-rolled copy of the rule under proof.
94
+ const {
95
+ isAccountVisible, isRollupVisible, accountVisibleSql, rollupVisibleSql,
96
+ } = require('../modules/platform-identity/account-visibility.js');
97
+
98
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
99
+
100
+ // One row per matrix cell D4 names, plus a plain control.
101
+ const ACCOUNTS = [
102
+ // active + public + NOT hidden — the plain control.
103
+ { github_id: '100', handle: 'plain-vis', github_login: 'plainvis', display_name: 'Plain Visible',
104
+ avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
105
+ disciplines: ['engineer'] },
106
+ // active + public + HIDDEN — findable by name, not by discipline.
107
+ { github_id: '101', handle: 'hidden-builder', github_login: 'hiddenbuilder', display_name: 'Hidden Builder',
108
+ avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true,
109
+ disciplines: ['artist'] },
110
+ // active + PRIVATE — absent entirely, findable or not.
111
+ { github_id: '102', handle: 'private-builder', github_login: 'privatebuilder', display_name: 'Private Builder',
112
+ avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false,
113
+ disciplines: ['engineer'] },
114
+ // PROVISIONAL — absent entirely.
115
+ { github_id: '103', handle: 'provisional-builder', github_login: 'provbuilder', display_name: 'Provisional Builder',
116
+ avatar_url: null, profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false,
117
+ disciplines: ['engineer'] },
118
+ // active + public + NOT hidden, but 'octocat' sits mid-handle — a query for
119
+ // it is NOT a prefix match, so this fixture exercises the FUZZY (`%`) leg
120
+ // specifically, not the LIKE prefix leg.
121
+ { github_id: '104', handle: 'jax-octocat', github_login: 'jaxoctocat', display_name: 'Jax Octocat',
122
+ avatar_url: null, profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
123
+ disciplines: ['ui'] },
124
+ ];
125
+
126
+ const engine = {
127
+ calls: [],
128
+ query: async (text, params = []) => {
129
+ const sql = String(text);
130
+ engine.calls.push({ sql, params });
131
+ if (engine.throwOnQuery) throw engine.throwOnQuery;
132
+ assert.match(sql, /WITH matches AS/, 'the query shape moved — update this fake alongside it');
133
+ assert.match(sql, /platform_identity_builder_project_activity/, 'the discipline CTE moved — update this fake alongside it');
134
+ // THE load-bearing assertions (the code-review finding this test now
135
+ // closes): the REAL statement text must literally contain the shared
136
+ // predicates, not merely SOME predicate — deleting accountVisibleSql from
137
+ // the 'matches' CTE, or rollupVisibleSql from the 'disc' CTE, fails HERE,
138
+ // independent of the fixture simulation below and independent of
139
+ // tests/effective_visibility_predicate.mjs's function-body-level check.
140
+ assert.ok(sql.includes(accountVisibleSql('pa')),
141
+ "the 'matches' CTE must join accountVisibleSql('pa') — the NAME-match existence floor");
142
+ assert.ok(sql.includes(rollupVisibleSql('pa2')),
143
+ "the 'disc' CTE must join rollupVisibleSql('pa2') — the DISCIPLINE-match rollup floor");
144
+
145
+ const [rawQ, likePattern, discipline, limit, offset] = params;
146
+ const q = String(rawQ || '').toLowerCase();
147
+ const like = String(likePattern || '').replace(/\\(.)/g, '$1').replace(/%$/, '').toLowerCase();
148
+
149
+ // The NAME predicate: real accountVisibleSql via its JS twin, applied to
150
+ // the fixture — never a hand-rolled restatement.
151
+ let rows = ACCOUNTS.filter(isAccountVisible).filter((a) => {
152
+ const handle = a.handle.toLowerCase();
153
+ const name = (a.display_name || '').toLowerCase();
154
+ return handle.startsWith(like) || name.startsWith(like) || handle.includes(q) || name.includes(q);
155
+ });
156
+
157
+ // The DISCIPLINE predicate: rollup-derived, so it floors on isRollupVisible
158
+ // (existence AND NOT hidden) exactly as the disc CTE's EXISTS clause does.
159
+ const disciplinesFor = (a) => (isRollupVisible(a) ? a.disciplines : null);
160
+
161
+ if (discipline) {
162
+ rows = rows.filter((a) => {
163
+ const d = disciplinesFor(a);
164
+ return Array.isArray(d) && d.includes(discipline);
165
+ });
166
+ }
167
+
168
+ const shaped = rows
169
+ .sort((a, b) => (a.handle.toLowerCase() === q ? -1 : b.handle.toLowerCase() === q ? 1 : 0))
170
+ .slice(offset, offset + limit)
171
+ .map((a) => ({
172
+ handle: a.handle,
173
+ github_login: a.github_login,
174
+ display_name: a.display_name,
175
+ avatar_url: a.avatar_url,
176
+ disciplines: disciplinesFor(a),
177
+ }));
178
+ return { rows: shaped };
179
+ },
180
+ };
181
+
182
+ // Pin the module doorway (ADR 0083) BEFORE the route loads, so its pool is the
183
+ // fake engine above and its auth-adjacent exports satisfy the router factory.
184
+ const moduleApiPath = require.resolve('../src/module-api.js');
185
+ require.cache[moduleApiPath] = {
186
+ id: moduleApiPath, filename: moduleApiPath, loaded: true,
187
+ exports: {
188
+ pool: engine,
189
+ corsPublicGet: () => (_req, _res, next) => next(),
190
+ logger: () => ({ error: () => {}, info: () => {}, warn: () => {} }),
191
+ },
192
+ };
193
+
194
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
195
+ const buildCommunitySearchRoutes = require('../modules/platform-identity/routes/community-search.js');
196
+
197
+ function buildApp() {
198
+ const app = express();
199
+ app.use(attachFail);
200
+ app.use('/api/bongos', buildCommunitySearchRoutes());
201
+ return app;
202
+ }
203
+
204
+ function listen(app) {
205
+ return new Promise((resolve) => {
206
+ const server = app.listen(0, '127.0.0.1', () => resolve({ server, port: server.address().port }));
207
+ });
208
+ }
209
+
210
+ function get(port, path) {
211
+ return new Promise((resolve, reject) => {
212
+ const req = http.request({ host: '127.0.0.1', port, method: 'GET', path }, (res) => {
213
+ const chunks = [];
214
+ res.on('data', (c) => chunks.push(c));
215
+ res.on('end', () => {
216
+ const text = Buffer.concat(chunks).toString('utf8');
217
+ let json = null; try { json = JSON.parse(text); } catch (_) {}
218
+ resolve({ status: res.statusCode, json, text, headers: res.headers });
219
+ });
220
+ });
221
+ req.on('error', reject);
222
+ req.end();
223
+ });
224
+ }
225
+
226
+ const { server, port } = await listen(buildApp());
227
+
228
+ // ---- D4 matrix, end to end --------------------------------------------------
229
+
230
+ await t('name-only search finds the plain visible account', async () => {
231
+ const r = await get(port, '/api/bongos/community/search?q=plain-vis');
232
+ assert.equal(r.status, 200);
233
+ const logins = r.json.results.map((x) => x.github_login);
234
+ assert.ok(logins.includes('plainvis'), 'the plain control must be found');
235
+ });
236
+
237
+ await t('a hide_stats account IS findable by NAME (D4: hiding removes the rollup, not the page)', async () => {
238
+ const r = await get(port, '/api/bongos/community/search?q=hidden-builder');
239
+ assert.equal(r.status, 200);
240
+ const hit = r.json.results.find((x) => x.github_login === 'hiddenbuilder');
241
+ assert.ok(hit, 'the hidden account must still be found by name');
242
+ assert.equal(hit.disciplines, null, 'but its disciplines never attach — the rollup stays suppressed');
243
+ });
244
+
245
+ await t('the SAME hide_stats account drops out of a DISCIPLINE-filtered search', async () => {
246
+ const r = await get(port, '/api/bongos/community/search?q=hidden-builder&discipline=artist');
247
+ assert.equal(r.status, 200);
248
+ const hit = r.json.results.find((x) => x.github_login === 'hiddenbuilder');
249
+ assert.ok(!hit, 'a discipline filter must exclude the hidden account even though it genuinely holds that discipline');
250
+ });
251
+
252
+ await t('a private account never appears — by name or by its real discipline', async () => {
253
+ const byName = await get(port, '/api/bongos/community/search?q=private-builder');
254
+ assert.ok(!byName.json.results.some((x) => x.github_login === 'privatebuilder'));
255
+ const byDiscipline = await get(port, '/api/bongos/community/search?q=private&discipline=engineer');
256
+ assert.ok(!byDiscipline.json.results.some((x) => x.github_login === 'privatebuilder'));
257
+ });
258
+
259
+ await t('a provisional account never appears', async () => {
260
+ const r = await get(port, '/api/bongos/community/search?q=provisional-builder');
261
+ assert.ok(!r.json.results.some((x) => x.github_login === 'provbuilder'));
262
+ });
263
+
264
+ await t('a case-differing, NON-PREFIX query hits the fuzzy leg (not just the LIKE prefix leg)', async () => {
265
+ // 'OCTOCAT' is upper-case and is NOT a prefix of 'jax-octocat' — only the
266
+ // trigram `%` leg (never the prefix LIKE leg) can find it.
267
+ const r = await get(port, '/api/bongos/community/search?q=OCTOCAT');
268
+ assert.equal(r.status, 200);
269
+ const hit = r.json.results.find((x) => x.github_login === 'jaxoctocat');
270
+ assert.ok(hit, 'a case-differing, non-prefix query must still find it via the fuzzy leg');
271
+ });
272
+
273
+ await t('the plain visible account IS found under its real discipline, and ABSENT under another', async () => {
274
+ const hit = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=engineer');
275
+ assert.ok(hit.json.results.some((x) => x.github_login === 'plainvis'), 'engineer is its real discipline');
276
+ const miss = await get(port, '/api/bongos/community/search?q=plain-vis&discipline=artist');
277
+ assert.ok(!miss.json.results.some((x) => x.github_login === 'plainvis'), 'artist is not its discipline');
278
+ });
279
+
280
+ await t('github_id never crosses the wire', async () => {
281
+ const r = await get(port, '/api/bongos/community/search?q=plain-vis');
282
+ assert.ok(!r.text.includes('github_id'), 'the internal join key must not appear in the response');
283
+ assert.ok(!r.text.includes('"100"') && !r.text.includes('"101"'));
284
+ });
285
+
286
+ // ---- validation --------------------------------------------------------------
287
+
288
+ await t('missing q → 400 bad_request', async () => {
289
+ const r = await get(port, '/api/bongos/community/search');
290
+ assert.equal(r.status, 400);
291
+ assert.equal(r.json.error.code, 'bad_request');
292
+ });
293
+
294
+ await t('blank q → 400 bad_request', async () => {
295
+ const r = await get(port, '/api/bongos/community/search?q=%20%20');
296
+ assert.equal(r.status, 400);
297
+ });
298
+
299
+ await t('too-short q → 400 query_too_short', async () => {
300
+ const r = await get(port, '/api/bongos/community/search?q=a');
301
+ assert.equal(r.status, 400);
302
+ assert.equal(r.json.error.code, 'query_too_short');
303
+ });
304
+
305
+ await t('oversized q → 400 query_too_long', async () => {
306
+ const long = 'a'.repeat(builderSearch.MAX_QUERY_CHARS + 1);
307
+ const r = await get(port, `/api/bongos/community/search?q=${long}`);
308
+ assert.equal(r.status, 400);
309
+ assert.equal(r.json.error.code, 'query_too_long');
310
+ });
311
+
312
+ await t('oversized discipline → 400 discipline_too_long', async () => {
313
+ const long = 'a'.repeat(builderSearch.MAX_DISCIPLINE_CHARS + 1);
314
+ const r = await get(port, `/api/bongos/community/search?q=plain&discipline=${long}`);
315
+ assert.equal(r.status, 400);
316
+ assert.equal(r.json.error.code, 'discipline_too_long');
317
+ });
318
+
319
+ await t('response is Cache-Control: no-store (hide must beat the cache, ADR 0171 D5)', async () => {
320
+ const r = await get(port, '/api/bongos/community/search?q=plain-vis');
321
+ assert.equal(r.headers['cache-control'], 'no-store');
322
+ });
323
+
324
+ await t('page.offset echoes the MAX_OFFSET clamp through the real route', async () => {
325
+ const r = await get(port, `/api/bongos/community/search?q=plain-vis&offset=${builderSearch.MAX_OFFSET + 5000}`);
326
+ assert.equal(r.status, 200);
327
+ assert.equal(r.json.page.offset, builderSearch.MAX_OFFSET, 'the route must echo the CLAMPED offset, not the raw query param');
328
+ });
329
+
330
+ await t('a query engine failure → 500 search_failed (never a leaked stack/500 HTML page)', async () => {
331
+ engine.throwOnQuery = new Error('DO-NOT-LEAK-connection string or stack detail');
332
+ try {
333
+ const r = await get(port, '/api/bongos/community/search?q=plain-vis');
334
+ assert.equal(r.status, 500);
335
+ assert.equal(r.json.error.code, 'search_failed');
336
+ assert.ok(!r.text.includes('DO-NOT-LEAK'), 'the raw error message must not reach the response body');
337
+ } finally {
338
+ engine.throwOnQuery = null;
339
+ }
340
+ });
341
+
342
+ // ---- rate limit -------------------------------------------------------------
343
+
344
+ await t('searchReadRateLimit: allows up to the cap, then 429s with Retry-After', () => {
345
+ const { searchReadRateLimit, READ_RATE_LIMIT } = buildCommunitySearchRoutes._internals;
346
+ const mkRes = () => {
347
+ const r = { _status: 200, _json: null, _headers: {} };
348
+ r.set = (k, v) => { r._headers[k] = v; return r; };
349
+ r.status = (s) => { r._status = s; return r; };
350
+ r.json = (j) => { r._json = j; return r; };
351
+ attachFail({}, r, () => {}); // installs res.fail() as the router does
352
+ return r;
353
+ };
354
+ const req = { ip: '203.0.113.77' }; // a unique test IP so the module Map bucket is clean
355
+ let allowed = 0;
356
+ for (let i = 0; i < READ_RATE_LIMIT; i++) {
357
+ const res = mkRes();
358
+ let nexted = false;
359
+ searchReadRateLimit(req, res, () => { nexted = true; });
360
+ if (nexted) allowed++;
361
+ }
362
+ assert.equal(allowed, READ_RATE_LIMIT, 'every call up to the cap must pass through');
363
+ const res = mkRes();
364
+ let nexted = false;
365
+ searchReadRateLimit(req, res, () => { nexted = true; });
366
+ assert.equal(nexted, false, 'the over-cap call must NOT call next()');
367
+ assert.equal(res._status, 429);
368
+ assert.equal(res._json.error.code, 'rate_limited');
369
+ assert.ok(res._headers['Retry-After'], 'a Retry-After header is set');
370
+ });
371
+
372
+ await t('searchReadRateLimit keys on the request IP only — one caller cannot throttle another', () => {
373
+ const { searchReadRateLimit, READ_RATE_LIMIT } = buildCommunitySearchRoutes._internals;
374
+ const mkRes = () => ({ set() { return this; }, status() { return this; }, json() { return this; } });
375
+ const reqA = { ip: '203.0.113.78' };
376
+ for (let i = 0; i < READ_RATE_LIMIT; i++) searchReadRateLimit(reqA, mkRes(), () => {});
377
+ let nexted = false;
378
+ searchReadRateLimit({ ip: '203.0.113.79' }, mkRes(), () => { nexted = true; });
379
+ assert.equal(nexted, true, 'a different IP is not affected by another IP hitting the cap');
380
+ });
381
+
382
+ server.close();
383
+
384
+ console.log(`\n${passed} passed, ${failed} failed`);
385
+ process.exit(failed === 0 ? 0 : 1);
@@ -79,6 +79,7 @@ import {
79
79
  isRecruiterDiscoverable,
80
80
  recruiterReachSql,
81
81
  scoutingListableSql,
82
+ scoutingNameOnlySql,
82
83
  recruiterSliverVisibleSql,
83
84
  isRecruiterSliverVisible,
84
85
  } from '../modules/platform-identity/account-visibility.js';
@@ -353,8 +354,11 @@ function accountStatements(code) {
353
354
  * That is a decision recorded in the owner file and asserted above — not a
354
355
  * reader quietly dropping a term, which is what this regex would otherwise let
355
356
  * through unnoticed.
357
+ * scoutingNameOnlySql (task 1002291) is the directory's second tier: it floors on
358
+ * existence like every public answer, but it admits HIDDEN accounts, so it is
359
+ * pinned below to one reader that selects identity columns and nothing else.
356
360
  */
357
- const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
361
+ const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|scoutingNameOnlySql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
358
362
 
359
363
  // ===========================================================================
360
364
  // Self-tests — the machinery is the guard, so it gets proved too.
@@ -458,12 +462,36 @@ t('scoutingListableSql is COMPOSED from the rollup AND the reach halves (task 10
458
462
  + 'leaderboards join rollupVisibleSql and the opt-out must not empty them');
459
463
  });
460
464
 
465
+ t('scoutingNameOnlySql is the directory\'s SECOND tier: existence AND hidden AND reach (task 1002291)', () => {
466
+ // Privacy spec D2, the "active · public · hide_stats" row's recruiter column:
467
+ // "listed name-only (if not opted out)". Hide decides the SHAPE of a directory
468
+ // row, the reach axis decides whether there is a row at all.
469
+ for (const alias of ['pa', undefined]) {
470
+ const tier = scoutingNameOnlySql(alias);
471
+ assert.ok(tier.includes(accountVisibleSql(alias)),
472
+ 'it floors on EXISTENCE like every public read — a private account is never listed, not even by name');
473
+ assert.ok(tier.includes(recruiterReachSql(alias)),
474
+ 'and on reach: opting out removes a hidden builder from the directory too');
475
+ assert.match(tier, new RegExp(`AND ${alias ? `${alias}\\.` : ''}hide_stats\\)`),
476
+ 'and admits exactly the HIDDEN accounts — the ones the ranked tier leaves out');
477
+ assert.doesNotMatch(tier, /NOT (?:\w+\.)?hide_stats/, 'never the unhidden ones, or a builder would be listed twice');
478
+ }
479
+ // The two tiers PARTITION the reachable, visible accounts: they differ in the
480
+ // hide term and in nothing else, so no builder can fall between them or sit
481
+ // in both.
482
+ const ranked = scoutingListableSql('pa').replace('AND NOT pa.hide_stats)', 'AND <HIDE>)');
483
+ const named = scoutingNameOnlySql('pa').replace('AND pa.hide_stats)', 'AND <HIDE>)');
484
+ assert.ok(ranked.includes('<HIDE>') && named.includes('<HIDE>'), 'both tiers carry the hide term where it is expected');
485
+ assert.equal(named, ranked, 'the name-only tier is the ranked predicate with the hide term flipped — nothing more');
486
+ });
487
+
461
488
  t('the alias is the one interpolated thing, and every builder refuses a non-identifier', () => {
462
489
  const inject = "pa; DROP TABLE platform_identity_accounts --";
463
490
  assert.throws(() => accountVisibleSql(inject), /bare SQL identifier/);
464
491
  assert.throws(() => rollupVisibleSql(inject), /bare SQL identifier/);
465
492
  assert.throws(() => recruiterReachSql(inject), /bare SQL identifier/);
466
493
  assert.throws(() => scoutingListableSql(inject), /bare SQL identifier/);
494
+ assert.throws(() => scoutingNameOnlySql(inject), /bare SQL identifier/);
467
495
  });
468
496
 
469
497
  t('the JS ports answer exactly what the SQL asks, row for row', () => {
@@ -697,6 +725,26 @@ t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (ta
697
725
  'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
698
726
  });
699
727
 
728
+ t('the NAME-ONLY tier has exactly one reader, and it selects identity and nothing else (task 1002291)', () => {
729
+ // scoutingNameOnlySql admits HIDDEN accounts by design. Joined by a read that
730
+ // returned a number, it would hand out exactly the record hide_stats withholds,
731
+ // while looking adopted to the classification rule above. So the predicate is
732
+ // pinned to its one reader, and that reader's statement to three identity
733
+ // columns and no rollup table.
734
+ const readers = scanned
735
+ .filter((f) => f.rel !== PREDICATE_OWNER && /\bscoutingNameOnlySql\s*\(/.test(f.code))
736
+ .map((f) => f.rel);
737
+ assert.deepEqual(readers, ['modules/platform-identity/scouting-name-only.js'],
738
+ 'the name-only tier is the recruiting directory\'s alone');
739
+ const reader = scanned.find((f) => f.rel === readers[0]);
740
+ const statements = accountStatements(reader.code);
741
+ assert.equal(statements.length, 1, 'one statement');
742
+ assert.match(statements[0].sql, /^SELECT github_login, display_name, avatar_url FROM platform_identity_accounts WHERE /,
743
+ 'selecting the three identity columns a hidden builder\'s public page already shows — never an id, a count or a date');
744
+ assert.doesNotMatch(reader.code, /platform_identity_builder_project_activity/,
745
+ 'and never touching the rollup table: presence in this tier must not depend on activity, or it would say who has built');
746
+ });
747
+
700
748
  t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
701
749
  const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
702
750
  const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');