@bongos/core 1.19.1015 → 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 (82) hide show
  1. package/.bongos-core.json +159 -94
  2. package/.claude/skills/planning-session/SKILL.md +1 -1
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -2
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0331-the-sky-is-the-ideators-home.md +12 -0
  9. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  10. package/docs/adr/0335-an-invite-is-a-pre-approved-row-on-the-projects-own-instance.md +113 -0
  11. package/docs/adr/0336-a-guilds-record-is-the-sum-of-what-its-members-already-show.md +201 -0
  12. package/docs/adr/README.md +3 -0
  13. package/docs/api/openapi.json +60 -5
  14. package/docs/api-reference.md +8 -2
  15. package/docs/architecture.md +4 -1
  16. package/docs/copy-inventory.md +5 -3
  17. package/docs/copy-registry.json +22 -4
  18. package/docs/file-map.md +1 -2
  19. package/docs/module-api-changelog.md +5 -1
  20. package/migrations/core_253_idea_filed_via.sql +59 -0
  21. package/modules/agents/pollers/event-router.js +2 -0
  22. package/modules/discord/discord-bugs.js +2 -0
  23. package/modules/discord/discord-inbound.js +3 -0
  24. package/modules/hall-ui/public/people.html +11 -0
  25. package/modules/hall-ui/public/people.js +77 -11
  26. package/modules/hall-ui/public/people.states.json +1 -1
  27. package/modules/hall-ui/public/sky-data.js +22 -8
  28. package/modules/hall-ui/public/sky-draw.js +3 -2
  29. package/modules/hall-ui/public/sky-panel.js +1 -1
  30. package/modules/ideas/born-of.js +55 -0
  31. package/modules/ideas/inbox.js +23 -8
  32. package/modules/ideas/projection.js +48 -0
  33. package/modules/ideas/routes/inbox.js +20 -0
  34. package/modules/lifecycle/sky.js +25 -4
  35. package/modules/platform-identity/account-visibility.js +33 -2
  36. package/modules/platform-identity/activity-snapshots.js +47 -0
  37. package/modules/platform-identity/builder-search.js +153 -0
  38. package/modules/platform-identity/migrations/platform_identity_022_activity_snapshots.sql +46 -0
  39. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  40. package/modules/platform-identity/module.json +1 -1
  41. package/modules/platform-identity/platform-identity.js +13 -0
  42. package/modules/platform-identity/routes/community-search.js +120 -0
  43. package/modules/platform-identity/routes/scouting.js +14 -7
  44. package/modules/platform-identity/scouting-name-only.js +47 -0
  45. package/modules/platform-identity/tests/activity-snapshots.mjs +68 -0
  46. package/modules/platform-identity/tests/platform-identity.mjs +21 -0
  47. package/modules/public-landing/public/projects-floor.states.json +9 -2
  48. package/modules/public-landing/public/projects.html +69 -4
  49. package/modules/ui-design/skills/impeccable/SKILL.md +1 -1
  50. package/modules/ui-design/styles/expedition/mock.html +16 -3
  51. package/package-lock.json +2 -2
  52. package/package.json +1 -1
  53. package/scripts/gds/capture.js +11 -0
  54. package/scripts/gds/run-unit-tests.js +4 -0
  55. package/src/module-api.js +1 -1
  56. package/tests/account_privacy_flags.mjs +4 -1
  57. package/tests/activity_snapshots_db.mjs +97 -0
  58. package/tests/card_apply_ui.mjs +297 -0
  59. package/tests/community_search_route.mjs +385 -0
  60. package/tests/discord_bugs.mjs +1 -0
  61. package/tests/discord_inbound.mjs +1 -0
  62. package/tests/effective_visibility_predicate.mjs +49 -1
  63. package/tests/goal_map_page.mjs +14 -0
  64. package/tests/guild_namespace_reserved.mjs +76 -0
  65. package/tests/hall_sky.mjs +33 -16
  66. package/tests/idea_filed_via.mjs +185 -0
  67. package/tests/landing_page.mjs +8 -0
  68. package/tests/people_name_only.mjs +262 -0
  69. package/tests/profile_rollup_consent.mjs +5 -1
  70. package/tests/profile_route.mjs +23 -5
  71. package/tests/project_admin_console_model.mjs +105 -0
  72. package/tests/recruiter_sliver_boundary.mjs +3 -0
  73. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  74. package/tests/sky_read.mjs +19 -2
  75. package/tests/ui_design_skills.mjs +9 -0
  76. package/tests/ui_design_styles.mjs +13 -0
  77. package/modules/public-landing/public/world/assets/clouds/cloud-island-large.png +0 -0
  78. package/modules/public-landing/public/world/assets/clouds/cloud-puff.png +0 -0
  79. package/modules/public-landing/public/world/assets/creatures/creature-whale-cargo.png +0 -0
  80. package/modules/public-landing/public/world/assets/creatures/creature-whale-glide.png +0 -0
  81. package/modules/public-landing/public/world/assets/plates/plate-clouds-near.png +0 -0
  82. package/modules/public-landing/public/world/assets/plates/scene-establishing.png +0 -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);
@@ -115,6 +115,7 @@ await t('recordInboundBug: linked Metic → filed at ready, kind=bug, attributed
115
115
  assert.equal(calls.create[0].kind, 'bug', 'the channel IS the classification');
116
116
  assert.equal(calls.create[0].filerRank, 'metic', 'the LIVE rank is handed to the matrix');
117
117
  assert.equal(calls.create[0].capturedBy, 9, 'and the reporter is attached to the capture');
118
+ assert.equal(calls.create[0].filedVia, 'discord', 'the door is a fact on the row (task 1004233)');
118
119
  assert.equal(calls.create[0].goalId, 7, 'the suggester found it a home');
119
120
  // task 1003067: no versionId is passed AT ALL. It used to be the hardcoded
120
121
  // 'GDS-V3' — a shipped version, which is idea 1000771. The goal carries it now.
@@ -83,6 +83,7 @@ await t('recordInboundIdea: linked author → idea attributed to builder + audit
83
83
  assert.equal(r.outcome, 'filed');
84
84
  assert.equal(r.ideaId, 77);
85
85
  assert.equal(calls.capture[0].capturedBy, 9);
86
+ assert.equal(calls.capture[0].filedVia, 'discord', 'the door is a fact on the row (task 1004233): this path calls the port, so it names it');
86
87
  assert.match(calls.capture[0].bodyMd, /filed from Discord #ideas by mona/);
87
88
  assert.equal(calls.audit[0].surface, 'discord_bot');
88
89
  assert.equal(calls.audit[0].builderId, 9);
@@ -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');
@@ -354,6 +354,20 @@ await test('the tour walks all three lenses here too, and hands the sky back to
354
354
  assert.equal(first.timers.pending(), 1, 'a first visit schedules the tour here as well');
355
355
  });
356
356
 
357
+ // ═══ born of a thought (part 5) ══════════════════════════════════════════════
358
+
359
+ await test('a well born of a thought wears it in the ledger and the panel — the fact from GET /sky\'s born, never a guess', async () => {
360
+ const sk = FOUR(); sk.born = { goals: { 9: 21 }, criteria: {} };
361
+ const p = await boot({ sky: sk, versions: versions() });
362
+ assert.equal(goal(p, '9').bornOf, '21');
363
+ assert.equal(goal(p, '7').bornOf, null);
364
+ p.key('l');
365
+ const row = (new RegExp('data-id="9"[\\s\\S]*?</button>').exec(p.rows()) || [''])[0];
366
+ assert.match(row, /born of a thought/);
367
+ p.SKY().pick({ kind: 'goal', ref: goal(p, '9') }, false);
368
+ assert.match(p.panel(), /born of a thought/);
369
+ });
370
+
357
371
  // ═══ the words are never markup ══════════════════════════════════════════════
358
372
 
359
373
  await test('a goal title never becomes markup — in the ledger, the panel or the hover card', async () => {
@@ -0,0 +1,76 @@
1
+ // tests/guild_namespace_reserved.mjs — the guild URL namespace is RESERVED where
2
+ // project slugs are decided (task 1002299, R23 guild design; task 1002912's rule).
3
+ //
4
+ // Project profiles live at the top level (`cloudbongos.com/<slug>`), so every
5
+ // top-level segment the hub uses for another kind of page must be on the reserved
6
+ // project-slug list in the SEO spec, or a project can register the word and shadow
7
+ // the namespace. `u` guards `/u/<handle>`; the guild design names its own segment,
8
+ // and task 1002912 asked for the word to be reserved there, not a route squatted.
9
+ //
10
+ // No slug validator exists yet (the SEO goal's R02 builds it from that list), so the
11
+ // spec IS the contract today. This pins the one property that can drift silently:
12
+ // the segment the guild ADR declares and the segment the spec reserves are the same.
13
+ //
14
+ // Run: node tests/guild_namespace_reserved.mjs
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { makeRunner } from './helpers.mjs';
20
+
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const ADR_DIR = path.join(ROOT, 'docs', 'adr');
23
+ const SEO_SPEC = path.join(ROOT, 'docs', 'specs', 'bongos-v1-seo-public-discoverability.md');
24
+ const GUILD_TASK = '1002299';
25
+
26
+ const { test, summary } = makeRunner();
27
+
28
+ // The guild design ADR, found by the task it records rather than by number: ADR
29
+ // numbers get renumbered when two sessions collide, the task id never changes.
30
+ function guildAdr() {
31
+ const hits = fs.readdirSync(ADR_DIR)
32
+ .filter((f) => /^\d{4}-.*\.md$/.test(f))
33
+ .filter((f) => {
34
+ const head = fs.readFileSync(path.join(ADR_DIR, f), 'utf8').replace(/\r\n/g, '\n').split('\n').slice(0, 12).join('\n');
35
+ return new RegExp(`^- \\*\\*Task:\\*\\*.*task ${GUILD_TASK}\\b`, 'm').test(head);
36
+ });
37
+ assert.equal(hits.length, 1, `exactly one ADR records task ${GUILD_TASK}; found ${JSON.stringify(hits)}`);
38
+ return fs.readFileSync(path.join(ADR_DIR, hits[0]), 'utf8').replace(/\r\n/g, '\n');
39
+ }
40
+
41
+ // The segment the ADR declares on its machine-readable `Namespace:` line.
42
+ function declaredSegment(adr) {
43
+ const m = adr.match(/^- \*\*Namespace:\*\* `\/([a-z0-9-]+)\/<slug>`/m);
44
+ assert.ok(m, 'the guild ADR carries a `- **Namespace:** `/<segment>/<slug>`` header line');
45
+ return m[1];
46
+ }
47
+
48
+ // The reserved words in the SEO spec's Routing bullet: the backticked list after "Reserved:".
49
+ function reservedWords() {
50
+ const spec = fs.readFileSync(SEO_SPEC, 'utf8').replace(/\r\n/g, '\n');
51
+ const routing = spec.split('\n').find((l) => l.startsWith('- **Routing:**'));
52
+ assert.ok(routing, 'the SEO spec still has its Routing bullet');
53
+ const m = routing.match(/Reserved: `([^`]+)`/);
54
+ assert.ok(m, 'the Routing bullet still names its reserved list as `Reserved: `…``');
55
+ return m[1].split(',').map((w) => w.trim()).filter(Boolean);
56
+ }
57
+
58
+ await test('the guild ADR declares one top-level namespace segment', () => {
59
+ const seg = declaredSegment(guildAdr());
60
+ assert.match(seg, /^[a-z0-9-]+$/);
61
+ });
62
+
63
+ await test('the SEO spec reserves the segment the guild ADR declares', () => {
64
+ const seg = declaredSegment(guildAdr());
65
+ const reserved = reservedWords();
66
+ assert.ok(reserved.includes(seg),
67
+ `a project could register "${seg}" and shadow /${seg}/<slug>; reserved list is ${JSON.stringify(reserved)}`);
68
+ });
69
+
70
+ await test('the builder-profile segment it was modelled on is still reserved', () => {
71
+ // The precedent the task names. If `u` ever leaves the list, the guild reservation
72
+ // above has lost the rule it was copied from, and both need a second look.
73
+ assert.ok(reservedWords().includes('u'));
74
+ });
75
+
76
+ summary();
@@ -303,27 +303,44 @@ await test('the claude layer is HIDDEN until filed_via exists, and every thought
303
303
  assert.ok(p.S().ideas.every((i) => i.origin === 'human'));
304
304
  });
305
305
 
306
- await test('once filed_via is on a row the pill appears, that thought waits behind it, and historic rows still show', async () => {
307
- const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'session' }), idea({ id: 3, title: 'A person filed this', filed_via: 'person' })]) });
306
+ await test('filed_via IS A LEDGER FACT (part 5): cli / api / discord wait behind the claude layer, hall / planning always show, and a row from before the column shows and says so', async () => {
307
+ const p = await boot({ mine: mine([star(), idea({ id: 2, title: 'A session filed this', filed_via: 'cli' }), idea({ id: 3, title: 'A person filed this', filed_via: 'hall' }), idea({ id: 4, title: 'Planned', filed_via: 'planning' }), idea({ id: 5, title: 'A poller filed this', filed_via: 'api' }), idea({ id: 6, title: 'From Discord', filed_via: 'discord' })]), sky: sky([well()]) });
308
308
  assert.equal(p.S().hasFiledVia, true);
309
309
  assert.equal(p.el('claudeBtn').hidden, false);
310
310
  const SKY = p.SKY();
311
- assert.equal(SKY.inLayer(findIdea(p, 2)), false, 'off by default: only what a person filed');
312
- assert.equal(SKY.inLayer(findIdea(p, 10)), true, 'a historic row with no filed_via shows (the owner\'s rule)');
313
- assert.equal(SKY.inLayer(findIdea(p, 3)), true);
311
+ same(SKY.SESSION_VIAS, ['cli', 'api', 'discord'], 'the doors a session files through');
312
+ for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), false, `${id} waits behind the pill — off by default`);
313
+ for (const id of [10, 3, 4]) assert.equal(SKY.inLayer(findIdea(p, id)), true, `${id} shows`);
314
+ assert.equal(findIdea(p, 10).filedVia, null, 'a historic row carries NULL, not a guess');
314
315
  p.click('claudeBtn');
315
- assert.equal(SKY.inLayer(findIdea(p, 2)), true, 'the pill reveals it');
316
- });
317
-
318
- await test('NO BORN-OF MARK IS DRAWN — the read does not exist yet, so nothing is guessed', async () => {
319
- const p = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
320
- const S = p.S();
321
- assert.equal(S.becameGoal.size, 0);
322
- assert.equal(S.becameCrit.size, 0);
323
- assert.ok(S.ideas.every((i) => i.shaped === null));
324
- assert.ok(S.goals.every((g) => g.bornOf === null && g.crit.every((c) => c.bornOf === null)));
316
+ for (const id of [2, 5, 6]) assert.equal(SKY.inLayer(findIdea(p, id)), true, 'the pill reveals them');
317
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 10) }, false);
318
+ assert.match(p.panel(), /filed before the ledger recorded where/, 'and the panel says so, in those words');
319
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 3) }, false);
320
+ assert.match(p.panel(), /from the hall/);
321
+ SKY.pick({ kind: 'idea', ref: findIdea(p, 2) }, false);
322
+ assert.match(p.panel(), /from the command line · by a session, under your name/);
323
+ });
324
+
325
+ await test('BORN OF A THOUGHT IS READ FROM GET /sky (part 5), never guessed: the goal wears the nucleus, the ring the notch, and the thought sits where it became', async () => {
326
+ const sk = sky([well()]); sk.born = { goals: { 7: 10 }, criteria: { 70: 12 } };
327
+ const p = await boot({ mine: mine([star(), coldBody({ id: 12, title: 'It became a ring' })]), sky: sk });
328
+ const S = p.S(); const SKY = p.SKY(); const g = S.goalById.get('7');
329
+ same(S.BORN, { goals: { 7: '10' }, criteria: { 70: '12' } }, 'ids are strings whatever the wire sent');
330
+ assert.equal(g.bornOf, '10'); assert.equal(g.crit[0].bornOf, '12');
331
+ assert.equal(S.becameGoal.get('10'), '7'); assert.equal(S.becameCrit.get('12'), g.crit[0]);
332
+ const shaped = findIdea(p, 10); assert.equal(shaped.shaped.kind, 'goal'); assert.equal(shaped.x, g.x); assert.equal(shaped.y, g.y);
333
+ const ring = findIdea(p, 12); assert.equal(ring.shaped.kind, 'ring');
334
+ assert.ok(Math.abs(Math.hypot(ring.x - g.x, ring.y - g.y) - SKY.ringR(g, 0, 2)) < 1e-6, 'on the first ring');
335
+ assert.equal(SKY.became(shaped), 'a goal — it shaped the space');
336
+ assert.match(SKY.became(ring), /^a criterion — a ring in /);
325
337
  p.key('l');
326
- assert.doesNotMatch(p.rows(), /born of a thought/);
338
+ assert.match(p.rows(), /shaped the space/);
339
+ SKY.pick({ kind: 'goal', ref: g }, false);
340
+ assert.match(p.panel(), /born of a thought/);
341
+ const none = await boot({ mine: mine(FIVE()), sky: sky([well()]) });
342
+ assert.equal(none.S().becameGoal.size, 0, 'no born read, no mark — nothing is guessed');
343
+ assert.ok(none.S().goals.every((x) => x.bornOf === null && x.crit.every((c) => c.bornOf === null)));
327
344
  });
328
345
 
329
346
  // ═══ the bands — derived, never named by hand ════════════════════════════════