@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.
- package/.bongos-core.json +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +56 -5
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +9 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_route.mjs +23 -5
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- 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
|
|
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
|
|
18
|
-
//
|
|
19
|
-
//
|
|
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
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
function
|
|
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
|
-
|
|
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 },
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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),
|