@bongos/core 1.19.1016 → 1.19.1018

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 (51) hide show
  1. package/.bongos-core.json +98 -48
  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/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
  8. package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
  9. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +56 -5
  12. package/docs/api-reference.md +7 -1
  13. package/docs/copy-inventory.md +5 -3
  14. package/docs/copy-registry.json +22 -4
  15. package/docs/file-map.md +1 -1
  16. package/docs/module-api-changelog.md +9 -0
  17. package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
  18. package/modules/hall-ui/public/people.html +11 -0
  19. package/modules/hall-ui/public/people.js +77 -11
  20. package/modules/hall-ui/public/people.states.json +1 -1
  21. package/modules/onboarding/routes/access-requests.js +31 -4
  22. package/modules/platform-identity/account-visibility.js +33 -2
  23. package/modules/platform-identity/builder-search.js +153 -0
  24. package/modules/platform-identity/hub-keys.js +51 -32
  25. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  26. package/modules/platform-identity/module.json +1 -1
  27. package/modules/platform-identity/routes/community-search.js +120 -0
  28. package/modules/platform-identity/routes/my-projects.js +33 -1
  29. package/modules/platform-identity/routes/scouting.js +14 -7
  30. package/modules/platform-identity/scouting-name-only.js +47 -0
  31. package/modules/public-landing/public/projects-floor.states.json +9 -2
  32. package/modules/public-landing/public/projects.html +69 -4
  33. package/modules/ui-design/styles/expedition/mock.html +16 -3
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/src/bongos/auth-idp.js +93 -48
  37. package/src/bongos/auth.js +2 -1
  38. package/src/module-api.js +7 -1
  39. package/tests/account_privacy_flags.mjs +4 -1
  40. package/tests/application_echo.mjs +100 -1
  41. package/tests/application_lifecycle.mjs +145 -6
  42. package/tests/card_apply_ui.mjs +297 -0
  43. package/tests/community_search_route.mjs +385 -0
  44. package/tests/effective_visibility_predicate.mjs +49 -1
  45. package/tests/idp_device_exchange.mjs +174 -0
  46. package/tests/module_api.mjs +1 -0
  47. package/tests/people_name_only.mjs +262 -0
  48. package/tests/profile_route.mjs +23 -5
  49. package/tests/recruiter_sliver_boundary.mjs +3 -0
  50. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  51. package/tests/ui_design_styles.mjs +13 -0
@@ -80,6 +80,24 @@ function normalizeLogin(raw) {
80
80
  return String(raw || '').trim().replace(/^@/, '');
81
81
  }
82
82
 
83
+ // The hub relay's signed application vouch rides in a HEADER, not the body: the
84
+ // strict validator would 400 an unknown field on every older core (ADR 0201 D2.2).
85
+ // Mirrored in modules/platform-identity/routes/my-projects.js (ADR 0091 §2).
86
+ const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
87
+
88
+ // vouchPendingRow — a vouched re-application takes over an UNVOUCHED pending row
89
+ // for its own login, name and note included, so a row someone else filed under
90
+ // that login cannot pre-empt the real applicant's vouch (ADR 0333 D4). A row
91
+ // already vouched is never re-stamped.
92
+ function vouchPendingRow({ login, displayName, note, vouch }) {
93
+ return pool.query(
94
+ `UPDATE access_requests
95
+ SET display_name = $2, note = $3, applicant_github_id = $4, applicant_handle = $5, vouched_at = $6
96
+ WHERE lower(github_login) = lower($1) AND status = 'pending' AND applicant_github_id IS NULL`,
97
+ [login, displayName, note, vouch.githubId, vouch.handle, new Date()],
98
+ );
99
+ }
100
+
83
101
  // How many applicant profiles one queue render may resolve from the hub (privacy
84
102
  // spec D7, task 1002972). The list itself returns up to 200 rows, and a render
85
103
  // that fired 200 cross-instance round-trips would make a reviewer wait on the
@@ -156,6 +174,10 @@ module.exports = function buildAccessRequestsRouter() {
156
174
  }
157
175
  const displayName = (req.body.display_name || '').trim() || null;
158
176
  const note = (req.body.note || '').trim() || null;
177
+ // A verified vouch that names this login → the account the hub confirmed; anything
178
+ // else → null, and the unvouched row this route always wrote (never a refusal,
179
+ // ADR 0201 D2.3). Descriptive only: no admission path reads it (ADR 0016).
180
+ const vouch = api.readApplicationVouch(req.get(APPLICATION_VOUCH_HEADER), { githubLogin: login });
159
181
 
160
182
  try {
161
183
  // Already a builder? They have an account — point them at sign-in instead
@@ -177,11 +199,12 @@ module.exports = function buildAccessRequestsRouter() {
177
199
  return res.fail('already_approved', { status: 409, message: "You're already approved — sign in with GitHub to enter." });
178
200
  }
179
201
 
202
+ // The three vouch columns are written in one statement, so they never disagree.
180
203
  const { rows } = await pool.query(
181
- `INSERT INTO access_requests (github_login, display_name, note)
182
- VALUES ($1, $2, $3)
204
+ `INSERT INTO access_requests (github_login, display_name, note, applicant_github_id, applicant_handle, vouched_at)
205
+ VALUES ($1, $2, $3, $4, $5, $6)
183
206
  RETURNING id, github_login, status, created_at`,
184
- [login, displayName, note]
207
+ [login, displayName, note, vouch ? vouch.githubId : null, vouch ? vouch.handle : null, vouch ? new Date() : null]
185
208
  );
186
209
  // Post the request to #member-approvals so an Archon can 👍/👎 it from
187
210
  // Discord (task 1100). Fire-and-forget + best-effort — never blocks or
@@ -195,6 +218,10 @@ module.exports = function buildAccessRequestsRouter() {
195
218
  // Partial unique index → there's already an open request for this login.
196
219
  // Treat as success-ish: the Archon already has them in the queue.
197
220
  if (err.code === '23505') {
221
+ if (vouch) {
222
+ try { await vouchPendingRow({ login, displayName, note, vouch }); }
223
+ catch (e) { log.error({ err: e.message }, '[gds] POST /access-requests: vouching the open request failed (non-fatal)'); }
224
+ }
198
225
  return res.status(200).json({ ok: true, already_pending: true });
199
226
  }
200
227
  console.error('[gds] POST /access-requests', err);
@@ -276,7 +303,7 @@ module.exports = function buildAccessRequestsRouter() {
276
303
  const { rows } = await pool.query(
277
304
  `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
278
305
  ar.created_at, ar.resolved_at,
279
- ar.applicant_github_id, ar.vouched_at,
306
+ ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
280
307
  (ar.applicant_github_id IS NOT NULL) AS vouched,
281
308
  rb.github_login AS resolved_by_login
282
309
  FROM access_requests ar
@@ -32,8 +32,13 @@
32
32
  // recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
33
33
  // spec D1. A reach predicate only: it never buys addressability.
34
34
  // scoutingListableSql THE DIRECTORY — numbers AND reach,
35
- // the ONE predicate the recruiting directory joins (ADR 0210, task
36
- // 1002290). Composed, never restated.
35
+ // the predicate the recruiting directory's ranked tier joins (ADR 0210,
36
+ // task 1002290). Composed, never restated.
37
+ // scoutingNameOnlySql THE DIRECTORY'S NAME-ONLY TIER —
38
+ // existence AND hidden AND reach (ADR 0334, task 1002291; privacy spec D2:
39
+ // a hidden builder is "listed name-only (if not opted out)"). The same
40
+ // predicate as the one above with the hide term flipped, so the two tiers
41
+ // partition the directory: hide decides a row's SHAPE, reach its presence.
37
42
  // recruiterSliverVisibleSql / isRecruiterSliverVisible THE PRE-ACCEPT
38
43
  // SLIVER — consent AND reach, WITHOUT the existence axis (ADR 0215, task
39
44
  // 1002968; privacy spec D3). The one predicate that deliberately does not
@@ -205,6 +210,31 @@ function scoutingListableSql(alias) {
205
210
  return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
206
211
  }
207
212
 
213
+ // scoutingNameOnlySql — WHO the recruiting directory lists by NAME ALONE: a
214
+ // publicly visible, reachable account that hid its cross-project record (ADR
215
+ // 0334, task 1002291). Privacy spec D2's recruiter column lists that account
216
+ // "name-only (if not opted out)"; before this the directory dropped it outright,
217
+ // reading hide as a second opt-out while Settings told the builder recruiters
218
+ // would see their name and picture.
219
+ //
220
+ // It is scoutingListableSql with the hide term FLIPPED and nothing else, shaped
221
+ // identically so the two tiers cannot overlap or leave a gap
222
+ // (tests/effective_visibility_predicate.mjs holds that). It keeps the EXISTENCE
223
+ // floor, so a private account is never listed even by name — that is the D3
224
+ // sliver's subject, at the archon floor (ADR 0215/0217).
225
+ //
226
+ // DANGEROUS OUTSIDE ITS ONE READER. It admits hidden accounts by design, so a
227
+ // read that joined it and returned a number would hand out exactly the record
228
+ // hide_stats withholds. scouting-name-only.js is its only reader and selects
229
+ // three identity columns; the guard pins both.
230
+ function scoutingNameOnlySql(alias) {
231
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
232
+ throw new Error('scoutingNameOnlySql: alias must be a bare SQL identifier');
233
+ }
234
+ const q = alias ? `${alias}.` : '';
235
+ return `((${accountVisibleSql(alias)} AND ${q}hide_stats) AND ${recruiterReachSql(alias)})`;
236
+ }
237
+
208
238
  // recruiterSliverVisibleSql — THE predicate the PRE-ACCEPT recruiter sliver
209
239
  // joins: the CONSENT floor AND the reach axis (ADR 0215, privacy spec D3; task
210
240
  // 1002968). modules/platform-identity/recruiter-sliver.js is its one reader.
@@ -256,6 +286,7 @@ module.exports = {
256
286
  isRecruiterDiscoverable,
257
287
  recruiterReachSql,
258
288
  scoutingListableSql,
289
+ scoutingNameOnlySql,
259
290
  recruiterSliverVisibleSql,
260
291
  isRecruiterSliverVisible,
261
292
  };
@@ -0,0 +1,153 @@
1
+ // modules/platform-identity/builder-search.js — the PUBLIC builder search (D4,
2
+ // docs/specs/bongos-v1-community-tab.md; task 1002979, goal 1000110 CT3). Answers
3
+ // "find a builder by name, optionally narrowed to a discipline" for the future
4
+ // /community page (tasks 1002980 / 1003363) — NOT the recruiting directory
5
+ // (routes/scouting.js), which stays a separate, project.curate/owner-gated
6
+ // surface with its own consent predicate, and NOT its archon-floor pre-accept
7
+ // sliver (recruiter-sliver.js). This surface reveals nothing beyond the same
8
+ // public-profile rules /profiles/:handle already applies (D8: no new disclosure
9
+ // class).
10
+ //
11
+ // TWO PREDICATES FROM account-visibility.js, ONE PER AXIS D4 NAMES:
12
+ // NAME MATCH — accountVisibleSql (existence: active + public). A
13
+ // hide_stats account is still findable BY NAME — hiding removes the
14
+ // rollup, not the page (the same rule /profiles/:handle already honors).
15
+ // DISCIPLINE MATCH — rollupVisibleSql (existence AND NOT hide_stats),
16
+ // because a discipline is ROLLUP-DERIVED
17
+ // (platform_identity_builder_project_activity.disciplines) and the rollup
18
+ // is exactly what hide_stats suppresses. A hidden account's disciplines are
19
+ // therefore never attached and never filterable — which is what keeps a
20
+ // discipline facet from re-exposing a hidden account's rollup by a side
21
+ // door. Private and provisional accounts never reach either predicate, so
22
+ // they are absent from every result regardless of query or filter.
23
+ // Both predicates are IMPORTED, never re-derived — the sole-owner rule
24
+ // tests/effective_visibility_predicate.mjs enforces.
25
+ //
26
+ // Matching: handle / display_name PREFIX (escaped LIKE) OR pg_trgm fuzzy (the
27
+ // `%` similarity operator) — the two legs D4 asks for. BOTH legs compare
28
+ // lower(column) against lower($1)/lower($2) — the `%` operator is
29
+ // case-SENSITIVE (unlike ILIKE, pg_trgm has no case-insensitive variant), so
30
+ // without the explicit lower() a query differing only in case would silently
31
+ // miss the fuzzy leg while the `sim` column (already lower()-folded) still
32
+ // scored it. Indexed by migration platform_identity_023_builder_search_trgm.sql
33
+ // — a GIN gin_trgm_ops index on lower(handle) and lower(display_name), which
34
+ // accelerates both legs since both now query that same expression.
35
+ // github_id never crosses the projection — handle is the address, exactly as
36
+ // every sibling public read in this module already treats it.
37
+ 'use strict';
38
+
39
+ const { pool: defaultPool } = require('../../src/module-api');
40
+ const { accountVisibleSql, rollupVisibleSql } = require('./account-visibility');
41
+
42
+ const DEFAULT_LIMIT = 20;
43
+ const MAX_LIMIT = 50;
44
+ // Deep OFFSET pagination on an unauthenticated route is a free scan-and-discard
45
+ // knob (?offset=999999999 still walks and throws away every matched row before
46
+ // the LIMIT) — capped so a caller can page a reasonable distance and no further.
47
+ const MAX_OFFSET = 1000;
48
+ // A 1-character query has no usable trigram and a near-unselective LIKE
49
+ // prefix, so it would materialize and sort a large share of the table before
50
+ // LIMIT on every anonymous request. 2 chars is the floor at which a trigram
51
+ // exists at all.
52
+ const MIN_QUERY_CHARS = 2;
53
+ const MAX_QUERY_CHARS = 100;
54
+ const MAX_DISCIPLINE_CHARS = 32; // mirrors upsertActivity's per-discipline cap
55
+
56
+ function clampLimit(limit) {
57
+ const n = parseInt(limit, 10);
58
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
59
+ return Math.min(n, MAX_LIMIT);
60
+ }
61
+
62
+ function clampOffset(offset) {
63
+ const n = parseInt(offset, 10);
64
+ if (!Number.isFinite(n) || n < 0) return 0;
65
+ return Math.min(n, MAX_OFFSET);
66
+ }
67
+
68
+ // Escape LIKE's own wildcard characters so a searcher's literal % or _ can't
69
+ // turn a prefix match into an unbounded pattern.
70
+ function escapeLikePrefix(s) {
71
+ return s.replace(/[\\%_]/g, (c) => `\\${c}`);
72
+ }
73
+
74
+ // searchBuilders — the one read behind GET /community/search. `q` is required
75
+ // (too short/empty/oversized → no results, never a throw — the route
76
+ // validates and 400s before calling this; this stays defensive on its own).
77
+ // `discipline` is optional; '' means no filter. Returns
78
+ // { results, limit, offset } — `limit`/`offset` are the CLAMPED values
79
+ // actually used, so a caller (the route) never re-derives them to echo back.
80
+ // A result's `disciplines` is null for a hidden account or one with no
81
+ // rollup activity.
82
+ async function searchBuilders({ q, discipline, limit, offset, pool = defaultPool } = {}) {
83
+ const query = typeof q === 'string' ? q.trim() : '';
84
+ const lim = clampLimit(limit);
85
+ const off = clampOffset(offset);
86
+ if (query.length < MIN_QUERY_CHARS || query.length > MAX_QUERY_CHARS) {
87
+ return { results: [], limit: lim, offset: off };
88
+ }
89
+ const disc = typeof discipline === 'string' ? discipline.trim().slice(0, MAX_DISCIPLINE_CHARS) : '';
90
+
91
+ const { rows } = await pool.query(
92
+ `WITH matches AS (
93
+ SELECT pa.github_id, pa.handle, pa.github_login, pa.display_name, pa.avatar_url,
94
+ (lower(pa.handle) = lower($1)) AS exact_handle,
95
+ GREATEST(similarity(lower(pa.handle), lower($1)),
96
+ similarity(lower(COALESCE(pa.display_name, '')), lower($1))) AS sim
97
+ FROM platform_identity_accounts pa
98
+ WHERE ${accountVisibleSql('pa')}
99
+ AND (
100
+ lower(pa.handle) LIKE lower($2) ESCAPE '\\'
101
+ OR lower(pa.display_name) LIKE lower($2) ESCAPE '\\'
102
+ OR lower(pa.handle) % lower($1)
103
+ OR lower(pa.display_name) % lower($1)
104
+ )
105
+ ), disc AS (
106
+ -- Scoped to the name matches only (never the whole activity table),
107
+ -- which is strictly smaller — though 'matches' itself is bounded only
108
+ -- by MIN_QUERY_CHARS' selectivity, not by a hard cap, since the
109
+ -- discipline filter (below) needs every match's disciplines before
110
+ -- LIMIT can apply.
111
+ SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
112
+ FROM (SELECT a.github_id, unnest(a.disciplines) AS d
113
+ FROM platform_identity_builder_project_activity a
114
+ WHERE a.github_id IN (SELECT github_id FROM matches)
115
+ AND EXISTS (
116
+ SELECT 1 FROM platform_identity_accounts pa2
117
+ WHERE pa2.github_id = a.github_id AND ${rollupVisibleSql('pa2')})) x
118
+ WHERE x.d IS NOT NULL
119
+ GROUP BY x.github_id
120
+ )
121
+ SELECT m.handle, m.github_login, m.display_name, m.avatar_url, d.disciplines
122
+ FROM matches m
123
+ LEFT JOIN disc d ON d.github_id = m.github_id
124
+ WHERE ($3::text = '' OR d.disciplines @> ARRAY[$3]::text[])
125
+ ORDER BY m.exact_handle DESC, m.sim DESC, m.handle
126
+ LIMIT $4 OFFSET $5`,
127
+ [query, `${escapeLikePrefix(query)}%`, disc, lim, off],
128
+ );
129
+ return {
130
+ results: rows.map((r) => ({
131
+ handle: r.handle,
132
+ github_login: r.github_login,
133
+ display_name: r.display_name,
134
+ avatar_url: r.avatar_url,
135
+ disciplines: Array.isArray(r.disciplines) ? r.disciplines : null,
136
+ })),
137
+ limit: lim,
138
+ offset: off,
139
+ };
140
+ }
141
+
142
+ module.exports = {
143
+ searchBuilders,
144
+ clampLimit,
145
+ clampOffset,
146
+ escapeLikePrefix,
147
+ DEFAULT_LIMIT,
148
+ MAX_LIMIT,
149
+ MAX_OFFSET,
150
+ MIN_QUERY_CHARS,
151
+ MAX_QUERY_CHARS,
152
+ MAX_DISCIPLINE_CHARS,
153
+ };
@@ -14,14 +14,18 @@
14
14
  // no PKI service.
15
15
  //
16
16
  // The assertion is a compact JWT (alg=EdDSA). The wire format is mirrored, by
17
- // necessity, in the instance-side verifier (src/bongos/auth.js verifyHubAssertion)
18
- // — the two sides live across the core<->module boundary and cannot share code
19
- // (ADR 0091 §2 boundary-forced duplication). Keep them in lockstep:
17
+ // necessity, in the instance-side verifiers (src/bongos/auth-idp.js) — the two
18
+ // sides live across the core<->module boundary and cannot share code (ADR 0091
19
+ // §2 boundary-forced duplication). Keep them in lockstep:
20
20
  // token = base64url(JSON(header)) "." base64url(JSON(payload)) "." base64url(sig)
21
21
  // header = { alg: "EdDSA", typ: "JWT" }
22
- // payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>,
23
- // github_login, display_name, avatar_url, iat, exp, jti }
22
+ // payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>, …, iat, exp, jti }
24
23
  // sig = Ed25519(privkey, utf8(`${b64u(header)}.${b64u(payload)}`))
24
+ // Three token kinds share that envelope, and each verifier demands exactly what
25
+ // the other two lack, so none is ever accepted as another:
26
+ // login assertion … = github_login, display_name, avatar_url (no purpose)
27
+ // logout token … = purpose:'backchannel-logout' (no identity)
28
+ // application vouch … = applicant_login, handle, purpose:'application'
25
29
  'use strict';
26
30
 
27
31
  const crypto = require('node:crypto');
@@ -75,24 +79,17 @@ function hubPublicKeyB64() {
75
79
  return pub.export({ type: 'spki', format: 'der' }).toString('base64');
76
80
  }
77
81
 
78
- // Mint an audience-scoped, short-lived EdDSA assertion for a federated client. The
79
- // payload carries IDENTITY ONLY (never a rank). `nowMs`/`jti` are injectable for
80
- // deterministic tests. Returns the compact token string.
81
- function mintHubAssertion(
82
- { clientId, githubId, githubLogin, displayName, avatarUrl },
83
- { signingKey, nowMs, jti } = {},
84
- ) {
82
+ // signHubToken — the ONE envelope every hub token is minted in: issuer, the
83
+ // caller's claims, then iat/exp (~120s) and a fresh jti. The three minters below
84
+ // differ only in the claims they pass. Throws when no signing key is configured.
85
+ function signHubToken(claims, { signingKey, nowMs, jti } = {}) {
85
86
  const key = signingKey || loadHubSigningKey();
86
87
  if (!key) throw new Error('hub signing key not configured');
87
88
  const iat = Math.floor((nowMs ?? Date.now()) / 1000);
88
89
  const header = { alg: 'EdDSA', typ: 'JWT' };
89
90
  const payload = {
90
91
  iss: ISSUER,
91
- aud: String(clientId),
92
- sub: String(githubId),
93
- github_login: githubLogin,
94
- display_name: displayName ?? null,
95
- avatar_url: avatarUrl ?? null,
92
+ ...claims,
96
93
  iat,
97
94
  exp: iat + Math.floor(ASSERTION_TTL_MS / 1000),
98
95
  jti: jti || crypto.randomBytes(12).toString('base64url'),
@@ -102,6 +99,22 @@ function mintHubAssertion(
102
99
  return `${signingInput}.${sig}`;
103
100
  }
104
101
 
102
+ // Mint an audience-scoped, short-lived EdDSA assertion for a federated client. The
103
+ // payload carries IDENTITY ONLY (never a rank). `nowMs`/`jti` are injectable for
104
+ // deterministic tests. Returns the compact token string.
105
+ function mintHubAssertion(
106
+ { clientId, githubId, githubLogin, displayName, avatarUrl },
107
+ opts = {},
108
+ ) {
109
+ return signHubToken({
110
+ aud: String(clientId),
111
+ sub: String(githubId),
112
+ github_login: githubLogin,
113
+ display_name: displayName ?? null,
114
+ avatar_url: avatarUrl ?? null,
115
+ }, opts);
116
+ }
117
+
105
118
  // The purpose marker that distinguishes a back-channel LOGOUT token from a login
106
119
  // assertion. Both are hub-signed EdDSA JWTs, but a logout token carries `purpose`
107
120
  // + NO identity fields, and a login assertion carries identity + NO `purpose` —
@@ -119,23 +132,28 @@ const LOGOUT_PURPOSE = 'backchannel-logout';
119
132
  // Same asymmetric signing + ~120s TTL + aud-binding as mintHubAssertion, so a
120
133
  // token minted for one instance can't be replayed against another. nowMs/jti
121
134
  // injectable for deterministic tests.
122
- function mintHubLogoutToken({ clientId, githubId }, { signingKey, nowMs, jti } = {}) {
123
- const key = signingKey || loadHubSigningKey();
124
- if (!key) throw new Error('hub signing key not configured');
125
- const iat = Math.floor((nowMs ?? Date.now()) / 1000);
126
- const header = { alg: 'EdDSA', typ: 'JWT' };
127
- const payload = {
128
- iss: ISSUER,
135
+ function mintHubLogoutToken({ clientId, githubId }, opts = {}) {
136
+ return signHubToken({ aud: String(clientId), sub: String(githubId), purpose: LOGOUT_PURPOSE }, opts);
137
+ }
138
+
139
+ // The purpose marker on an APPLICATION VOUCH — kept in lockstep with the instance
140
+ // verifier (src/bongos/auth-idp.js HUB_APPLICATION_PURPOSE).
141
+ const APPLICATION_PURPOSE = 'application';
142
+
143
+ // Mint the APPLICATION VOUCH the join relay sends a project (ADR 0201 D2, ADR 0333):
144
+ // "this request comes from this account" — identity + handle, never a rank or record.
145
+ // The login rides as `applicant_login`, NOT `github_login`: every login verifier any
146
+ // core ever shipped demands `github_login`, so even a core that predates the purpose
147
+ // refusal cannot accept a vouch as a device sign-in. `handle` is null unless the
148
+ // caller cleared the consent floor.
149
+ function mintHubApplicationVouch({ clientId, githubId, githubLogin, handle }, opts = {}) {
150
+ return signHubToken({
129
151
  aud: String(clientId),
130
152
  sub: String(githubId),
131
- purpose: LOGOUT_PURPOSE,
132
- iat,
133
- exp: iat + Math.floor(ASSERTION_TTL_MS / 1000),
134
- jti: jti || crypto.randomBytes(12).toString('base64url'),
135
- };
136
- const signingInput = `${b64uJson(header)}.${b64uJson(payload)}`;
137
- const sig = crypto.sign(null, Buffer.from(signingInput, 'utf8'), key).toString('base64url');
138
- return `${signingInput}.${sig}`;
153
+ applicant_login: githubLogin,
154
+ handle: handle ?? null,
155
+ purpose: APPLICATION_PURPOSE,
156
+ }, opts);
139
157
  }
140
158
 
141
159
  module.exports = {
@@ -146,6 +164,7 @@ module.exports = {
146
164
  loadHubSigningKey,
147
165
  hubSigningConfigured,
148
166
  hubPublicKeyB64,
167
+ mintHubApplicationVouch,
149
168
  mintHubAssertion,
150
169
  mintHubLogoutToken,
151
170
  };
@@ -0,0 +1,40 @@
1
+ -- platform_identity_023_builder_search_trgm.sql — GIN trigram indexes for the
2
+ -- public builder search (D4, docs/specs/bongos-v1-community-tab.md; task
3
+ -- 1002979). builder-search.js's searchBuilders() queries
4
+ -- lower(handle) LIKE lower($2) OR lower(handle) % lower($1) (and the same
5
+ -- pair for display_name) against platform_identity_accounts on EVERY request
6
+ -- to an UNAUTHENTICATED, rate-limited-but-public route — an unindexed
7
+ -- sequential scan there is a cost every anonymous caller can trigger.
8
+ --
9
+ -- INDEXED ON THE EXPRESSION, NOT THE BARE COLUMN. Both query legs compare
10
+ -- lower(handle)/lower(display_name), so a plain gin_trgm_ops index on the raw
11
+ -- column would never be used — Postgres only matches a functional index
12
+ -- against the identical expression. One index per column then serves BOTH
13
+ -- legs (prefix LIKE and % fuzzy), because pg_trgm's GIN opclass supports LIKE
14
+ -- as well as the trigram similarity operators.
15
+ --
16
+ -- pg_trgm itself is already enabled repo-wide by migrations/103_search_chunks.sql
17
+ -- (a core migration, so it runs before any module migration) — nothing to
18
+ -- create here.
19
+ --
20
+ -- WHY NOT CONCURRENTLY. Same reasoning core_222 and modules/ideas/migrations/-
21
+ -- ideas_004_lineage_route_index.sql recorded for their own indexes: CREATE
22
+ -- INDEX CONCURRENTLY cannot run inside a transaction block, and this file is
23
+ -- wrapped BEGIN/COMMIT so it lands atomically. Acceptable here specifically
24
+ -- because platform_identity_accounts is a hub-only, human-signup-paced table
25
+ -- (one row per platform account) — the build is a light, one-time lock against
26
+ -- a small table, not a write-hot one.
27
+ --
28
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: index
29
+ -- creation only, no column/type change, so the previous release runs
30
+ -- unmodified against this schema. Idempotent (IF NOT EXISTS).
31
+
32
+ BEGIN;
33
+
34
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_handle_trgm
35
+ ON platform_identity_accounts USING gin (lower(handle) gin_trgm_ops);
36
+
37
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_display_name_trgm
38
+ ON platform_identity_accounts USING gin (lower(display_name) gin_trgm_ops);
39
+
40
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep", "visibility-pull"]
16
16
  },
@@ -0,0 +1,120 @@
1
+ // modules/platform-identity/routes/community-search.js — GET /community/search,
2
+ // the PUBLIC builder-search read behind the future /community page (D4,
3
+ // docs/specs/bongos-v1-community-tab.md; task 1002979, goal 1000110 CT3). A
4
+ // sibling of public-profile.js and scouting.js — same module, same doorway,
5
+ // DIFFERENT surface: this is name+discipline search under exactly the
6
+ // public-profile visibility rules, never the project.curate/owner-gated
7
+ // recruiting directory (routes/scouting.js `GET /scouting`) and never its
8
+ // archon-floor pre-accept sliver (`GET /scouting/:handle`). The visibility
9
+ // decision lives entirely in ../builder-search.js (account-visibility.js's
10
+ // shared predicates); this file is the thin HTTP shell — validation, the
11
+ // per-IP rate limit, the response envelope.
12
+ //
13
+ // Reaches core only through the doorway (ADR 0083).
14
+ 'use strict';
15
+
16
+ const express = require('express');
17
+ const api = require('../../../src/module-api');
18
+ const builderSearch = require('../builder-search');
19
+
20
+ const { pool, corsPublicGet } = api;
21
+
22
+ // Resolved at CALL time, never at load — the scouting.js precedent: a proof
23
+ // that stubs a PARTIAL doorway (pool + the auth gates only) must still be able
24
+ // to require() this file, and a module-scope api.logger() call would break
25
+ // that. A logging line is not worth breaking a privacy proof over.
26
+ let resolvedLog = null;
27
+ const log = {
28
+ error: (msg) => {
29
+ if (!resolvedLog) resolvedLog = typeof api.logger === 'function' ? api.logger('platform-identity') : { error: () => {} };
30
+ resolvedLog.error(msg);
31
+ },
32
+ };
33
+
34
+ // Per-IP read cap for this UNAUTHENTICATED route (the public-profile.js R05
35
+ // posture: the global limiter only meters WRITES, so an uncapped scraper could
36
+ // otherwise run the trigram leg at line rate). Vendored, not shared — the core
37
+ // /public/* limiter is not on the doorway, and each public GET here owns its
38
+ // own budget rather than reaching into a sibling module's copy.
39
+ const READ_RATE_WINDOW_MS = 60 * 1000;
40
+ const READ_RATE_LIMIT = 60;
41
+ const buckets = new Map(); // ip -> timestamp[]
42
+
43
+ const sweepTimer = setInterval(() => {
44
+ const cutoff = Date.now() - READ_RATE_WINDOW_MS;
45
+ for (const [k, arr] of buckets) {
46
+ while (arr.length && arr[0] < cutoff) arr.shift();
47
+ if (arr.length === 0) buckets.delete(k);
48
+ }
49
+ }, 5 * 60 * 1000);
50
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref();
51
+
52
+ function searchReadRateLimit(req, res, next) {
53
+ const now = Date.now();
54
+ const key = req.ip || req.socket?.remoteAddress || 'unknown';
55
+ let bucket = buckets.get(key);
56
+ if (!bucket) { bucket = []; buckets.set(key, bucket); }
57
+ const cutoff = now - READ_RATE_WINDOW_MS;
58
+ while (bucket.length && bucket[0] < cutoff) bucket.shift();
59
+ if (bucket.length >= READ_RATE_LIMIT) {
60
+ const retryAfter = Math.max(1, Math.ceil((bucket[0] + READ_RATE_WINDOW_MS - now) / 1000));
61
+ res.set('Retry-After', String(retryAfter));
62
+ res.set('X-RateLimit-Scope', 'community-search-read');
63
+ res.set('X-RateLimit-Limit', String(READ_RATE_LIMIT));
64
+ return res.fail('rate_limited', 429, { scope: 'community-search-read', retry_after_seconds: retryAfter });
65
+ }
66
+ bucket.push(now);
67
+ return next();
68
+ }
69
+
70
+ module.exports = function communitySearchRoutes() {
71
+ const router = express.Router();
72
+
73
+ router.use('/community/search', corsPublicGet('/community/search'));
74
+
75
+ // rank: public — a name/discipline search under EXACTLY the public-profile
76
+ // visibility rules (D4): a hide_stats account is findable by name but drops
77
+ // out of a discipline-filtered result (disciplines are rollup-derived, and
78
+ // the rollup is what hide_stats suppresses); private and provisional
79
+ // accounts never appear. no-store: the same hide-must-beat-the-cache posture
80
+ // as /profiles/:handle (ADR 0171 D5) — this reads the identical predicate
81
+ // and a flip must be reflected on the very next request.
82
+ router.get('/community/search', searchReadRateLimit, async (req, res) => {
83
+ const q = (typeof req.query.q === 'string' ? req.query.q : '').trim();
84
+ if (!q) {
85
+ return res.fail('bad_request', { status: 400, message: 'q (a non-empty search string) is required' });
86
+ }
87
+ if (q.length < builderSearch.MIN_QUERY_CHARS) {
88
+ return res.fail('query_too_short', { status: 400, message: `q must be at least ${builderSearch.MIN_QUERY_CHARS} characters` });
89
+ }
90
+ if (q.length > builderSearch.MAX_QUERY_CHARS) {
91
+ return res.fail('query_too_long', { status: 400, message: `q must be at most ${builderSearch.MAX_QUERY_CHARS} characters` });
92
+ }
93
+ const discipline = (typeof req.query.discipline === 'string' ? req.query.discipline : '').trim();
94
+ if (discipline.length > builderSearch.MAX_DISCIPLINE_CHARS) {
95
+ return res.fail('discipline_too_long', { status: 400, message: `discipline must be at most ${builderSearch.MAX_DISCIPLINE_CHARS} characters` });
96
+ }
97
+ try {
98
+ const { results, limit, offset } = await builderSearch.searchBuilders({
99
+ q, discipline, limit: req.query.limit, offset: req.query.offset, pool,
100
+ });
101
+ res.set('Cache-Control', 'no-store');
102
+ res.json({
103
+ query: q,
104
+ discipline: discipline || null,
105
+ count: results.length,
106
+ results,
107
+ page: { limit, offset },
108
+ });
109
+ } catch (err) {
110
+ log.error(`GET /community/search failed: ${err && err.message}`);
111
+ res.fail('search_failed', { status: 500, message: 'internal error' });
112
+ }
113
+ });
114
+
115
+ return router;
116
+ };
117
+
118
+ // Exported for tests — the pure rate-limit bucket logic + limits (the
119
+ // modules/memory/routes/search.js precedent).
120
+ module.exports._internals = { searchReadRateLimit, READ_RATE_LIMIT, READ_RATE_WINDOW_MS };
@@ -25,6 +25,8 @@ const api = require('../../../src/module-api');
25
25
  const pi = require('../platform-identity');
26
26
  const projectStats = require('../project-stats');
27
27
  const applicationEcho = require('../application-echo');
28
+ const hubKeys = require('../hub-keys');
29
+ const { isAccountActive } = require('../account-visibility');
28
30
  const { projectPublishability } = require('../publish-gate');
29
31
 
30
32
  const { pool, requireBuilder, validateOrRespond, userAgent } = api;
@@ -116,6 +118,35 @@ function relayApplicantName(displayName) {
116
118
  return name || null;
117
119
  }
118
120
 
121
+ // Where the relay carries the vouch — mirrored by the project's public POST
122
+ // (modules/onboarding/routes/access-requests.js; boundary-forced, ADR 0091 §2).
123
+ const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
124
+
125
+ // applicationVouchHeaders — the signed vouch for the HUB SESSION's own account
126
+ // (never a body field), as headers to add, or {} (ADR 0333). Best-effort, never a
127
+ // gate. The handle rides only past isAccountActive — the floor the applicant view
128
+ // itself applies, so the vouch shows no handle the reviewer's queue would not.
129
+ async function applicationVouchHeaders(client, builder) {
130
+ const signingKey = hubKeys.loadHubSigningKey();
131
+ if (!signingKey || !pi.isNumericGithubId(builder.github_id)) return {};
132
+ let handle = null;
133
+ try {
134
+ const account = await pi.getAccount(builder.github_id, { pool });
135
+ if (account && isAccountActive(account)) handle = account.handle || null;
136
+ } catch (e) {
137
+ api.logger('platform-identity').warn(`vouch handle read failed, vouching without one (non-fatal): ${e && e.message}`);
138
+ }
139
+ try {
140
+ const vouch = hubKeys.mintHubApplicationVouch({
141
+ clientId: client.client_id, githubId: builder.github_id, githubLogin: builder.github_login, handle,
142
+ }, { signingKey });
143
+ return { [APPLICATION_VOUCH_HEADER]: vouch };
144
+ } catch (e) {
145
+ api.logger('platform-identity').warn(`application vouch mint failed, relaying unvouched (non-fatal): ${e && e.message}`);
146
+ return {};
147
+ }
148
+ }
149
+
119
150
  module.exports = function myProjectsRoutes() {
120
151
  const router = express.Router();
121
152
 
@@ -202,10 +233,11 @@ module.exports = function myProjectsRoutes() {
202
233
  // the project decides (open-join, or request/invite per ADR 0112). We post to
203
234
  // the registry-trusted origin, never the caller's raw input.
204
235
  let outcome = null;
236
+ const vouchHeaders = await applicationVouchHeaders(client, req.builder);
205
237
  try {
206
238
  const r = await fetch(`${client.origin}/api/bongos/access-requests`, {
207
239
  method: 'POST',
208
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
240
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(), ...vouchHeaders },
209
241
  body: JSON.stringify({
210
242
  github_login: req.builder.github_login,
211
243
  display_name: relayApplicantName(req.builder.display_name),