@bongos/core 1.19.579 → 1.19.580

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 (33) hide show
  1. package/.bongos-core.json +60 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +73 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +21 -18
  12. package/docs/copy-registry.json +50 -23
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +2 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/hall-ui/public/oversight.css +7 -0
  17. package/modules/hall-ui/public/watch.js +75 -0
  18. package/modules/onboarding/routes/access-requests.js +56 -1
  19. package/modules/platform-identity/applicant-profile.js +158 -0
  20. package/modules/platform-identity/application-echo.js +50 -0
  21. package/modules/platform-identity/recruiter-sliver.js +19 -0
  22. package/modules/platform-identity/routes/sso.js +126 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/bongos/auth-admission.js +61 -0
  26. package/src/bongos/auth.js +2 -1
  27. package/src/module-api.js +11 -1
  28. package/tests/adr_renumber_integrity.mjs +84 -0
  29. package/tests/applicant_profile_boundary.mjs +641 -0
  30. package/tests/applicant_profile_read_bounds.mjs +95 -0
  31. package/tests/module_api.mjs +1 -0
  32. package/tests/recruiter_sliver_boundary.mjs +35 -2
  33. package/tests/watch_applications_queue.mjs +94 -0
@@ -80,6 +80,57 @@ function normalizeLogin(raw) {
80
80
  return String(raw || '').trim().replace(/^@/, '');
81
81
  }
82
82
 
83
+ // How many applicant profiles one queue render may resolve from the hub (privacy
84
+ // spec D7, task 1002972). The list itself returns up to 200 rows, and a render
85
+ // that fired 200 cross-instance round-trips would make a reviewer wait on the
86
+ // hub to read a queue that is perfectly readable without it.
87
+ //
88
+ // The cap bites on VOUCHED rows only — an unvouched row has no account to
89
+ // resolve and costs nothing — and it is applied to the list AS ORDERED, newest
90
+ // first, so the rows a reviewer actually acts on are the ones that carry a
91
+ // profile. Beyond it, `profile` is null and the row renders exactly as an
92
+ // unvouched one does: the queue never degrades into an error, it degrades into
93
+ // less detail. Raising this number buys detail and costs latency; it changes
94
+ // nothing about WHAT may be disclosed.
95
+ const MAX_LIVE_PROFILE_READS = 25;
96
+
97
+ // attachApplicantProfiles(rows) — the LIVE half of D7, and "live" is the whole
98
+ // design rather than a performance note. Nothing about the applicant is stored
99
+ // on the access_requests row; the profile is resolved from the hub ON EVERY
100
+ // RENDER, so an applicant who goes private, hides their stats, or loses their
101
+ // account between applying and being reviewed is answered by the NEXT read of
102
+ // this queue (criterion C2's "immediately"). A snapshot taken at apply time
103
+ // would keep publishing a consent that had since been withdrawn.
104
+ //
105
+ // KEYED ON THE VOUCH, NEVER ON github_login. `applicant_github_id` is the hub's
106
+ // own assertion about who filed this request (core_230); the login is a string
107
+ // anybody may type into the one PUBLIC unauthenticated write on this API.
108
+ // Resolving a cross-project record against an unvouched login is precisely the
109
+ // impersonation surface the vouch exists to close — file a request naming a
110
+ // well-known builder, let the owner read that builder's excellent record, and
111
+ // walk in. So an unvouched row gets `profile: null`, always, and the queue's own
112
+ // "Name unvouched" pill already tells the reviewer why.
113
+ //
114
+ // Each read fails soft to null independently (the doorway port swallows), so one
115
+ // slow or missing account cannot fail the others or the listing.
116
+ async function attachApplicantProfiles(rows) {
117
+ // The budget is spent UP FRONT, by index, rather than by decrementing a shared
118
+ // counter inside the concurrent callbacks. Both are correct today — `.map`
119
+ // invokes each async callback synchronously up to its first `await`, so the
120
+ // decrements happen in list order before any hub call starts — but resting a
121
+ // disclosure-shaped cap on that guarantee is the kind of correctness that
122
+ // breaks silently the day someone reaches for a concurrency helper here. This
123
+ // form is correct for a reason a reader can see without knowing that rule.
124
+ const resolvable = new Set();
125
+ rows.forEach((r, i) => {
126
+ if (r.applicant_github_id && resolvable.size < MAX_LIVE_PROFILE_READS) resolvable.add(i);
127
+ });
128
+ return Promise.all(rows.map(async (r, i) => ({
129
+ ...r,
130
+ profile: resolvable.has(i) ? await api.readApplicantProfileFromHub(r.applicant_github_id) : null,
131
+ })));
132
+ }
133
+
83
134
  module.exports = function buildAccessRequestsRouter() {
84
135
  const router = express.Router();
85
136
 
@@ -235,7 +286,11 @@ module.exports = function buildAccessRequestsRouter() {
235
286
  LIMIT 200`,
236
287
  [status]
237
288
  );
238
- res.json({ requests: rows });
289
+ // C4's other half (privacy spec D7, task 1002972): the applicant's own
290
+ // profile, as their OWN privacy rules would show it, resolved from the hub
291
+ // at render. See attachApplicantProfiles — it is keyed on the vouch, never
292
+ // on the login, and every row it cannot resolve carries `profile: null`.
293
+ res.json({ requests: await attachApplicantProfiles(rows) });
239
294
  } catch (err) {
240
295
  log.error('[gds] GET /access-requests', err);
241
296
  res.fail('list_failed', { status: 500, message: 'internal error' });
@@ -0,0 +1,158 @@
1
+ // modules/platform-identity/applicant-profile.js — THE D7 RULE: what a reviewer
2
+ // sees of the person who applied to their project (privacy spec D7, task
3
+ // 1002972, goal 1000045).
4
+ //
5
+ // ONE RULE, NO NEW DISCLOSURE CLASS. That is D7's whole claim, and this file is
6
+ // where it is true or not:
7
+ //
8
+ // PUBLIC account → the SAME payload GET /profiles/:handle already serves —
9
+ // the identity band plus the cross-project rollup, which is
10
+ // null when `hide_stats` is set. Not a copy of that shape:
11
+ // the rollup half is getPublicProfileExtras, the very port
12
+ // the public page calls.
13
+ // PRIVATE account → the D3 sliver's projection, built by recruiter-sliver's
14
+ // own sliverShapeFor — again the same code, not a similar
15
+ // one. Name-only under `hide_stats`, by that file's rule.
16
+ // PROVISIONAL, or an account that never accepted the terms → NOTHING (null).
17
+ //
18
+ // So a reviewer never sees a field that some existing surface would not already
19
+ // have shown them. A THIRD shape here would BE the new disclosure class D7
20
+ // forbids, which is why neither branch builds its own object.
21
+ //
22
+ // EVALUATED LIVE, AND THAT IS A REQUIREMENT RATHER THAN AN IMPLEMENTATION NOTE
23
+ // (spec C2, D7). Nothing here is stored, cached or snapshotted at apply time. An
24
+ // applicant who flips `hide_stats`, goes private, or has their account
25
+ // deactivated between applying and being reviewed is answered by the NEXT render
26
+ // — because the next render is the only place the answer exists. A snapshot
27
+ // taken at apply time would keep publishing a withdrawn consent for as long as
28
+ // the request sat in the queue, which is exactly the failure C2's "immediately"
29
+ // is written against.
30
+ //
31
+ // THE CONSENT IS THE APPLICATION, AND THAT IS THE ONE PLACE THIS DIFFERS FROM
32
+ // THE RECRUITER SLIVER — stated out loud because it is the decision a reader
33
+ // will want to check. getRecruiterSliver floors on recruiterSliverVisibleSql =
34
+ // consent AND *recruiting reach*, because a recruiter browsing a directory has
35
+ // been invited by nobody. An applicant has done something a browsing recruiter
36
+ // has not: spec D7 says it in those words — "applying is an explicit act". So
37
+ // reusing getRecruiterSliver verbatim here would be WRONG in the direction that
38
+ // looks safe: `recruiter_discoverable` defaults to FALSE for a private account,
39
+ // so a private builder who deliberately applied would show their reviewer
40
+ // nothing at all, and criterion C4 would be satisfied by an empty box. The reach
41
+ // opt-out governs the RECRUITING SURFACE (account-visibility.js says exactly
42
+ // that of scoutingListableSql); it was never a gate on a door the account itself
43
+ // knocked on.
44
+ //
45
+ // WHAT STILL FLOORS IT, so "the application is the consent" cannot be read as
46
+ // "applying opts you into everything":
47
+ // * accountActiveSql — profile_state='active' AND terms_accepted_at IS NOT
48
+ // NULL. A provisional account (no human ever witnessed at the hub) and an
49
+ // account that never accepted the platform terms disclose NOTHING here, the
50
+ // same as on every other surface. This is the shared predicate, called, not
51
+ // re-spelled (tests/effective_visibility_predicate.mjs enforces that).
52
+ // * hide_stats — untouched, in both branches, by the two ports themselves.
53
+ // * the existence axis is deliberately NOT a floor, for the same reason the D3
54
+ // sliver drops it: the private account is precisely this rule's subject, and
55
+ // flooring on accountVisibleSql would empty the private branch of everything
56
+ // it exists to carry.
57
+ //
58
+ // NOT AUTHORITY (ADR 0016). A reviewer holding this view holds a description,
59
+ // never a permission: nothing here says the applicant may enter, and admission
60
+ // still reads status='invited' and nothing else (hasInvitedAccessRequest). It
61
+ // does not even say they applied — the CALLER must prove that first; see
62
+ // applicationBacksProfileRead in ./application-echo.js, which is the gate the
63
+ // one route over this port is built on.
64
+ //
65
+ // WHO MAY CALL IT is not this file's question (routes/sso.js gates the one
66
+ // surface). WHAT crosses is decided here, and by the two ports it composes.
67
+ //
68
+ // tests/applicant_profile_boundary.mjs is the proof.
69
+ 'use strict';
70
+
71
+ const pi = require('./platform-identity');
72
+ const recruiterSliver = require('./recruiter-sliver');
73
+ const { accountActiveSql, isAccountVisible } = require('./account-visibility');
74
+
75
+ // The two shapes this port may answer with, as a closed vocabulary. Exported so
76
+ // the proof asserts against these constants rather than a copy of the strings —
77
+ // a third view added here and quietly added to the test's own expectation is the
78
+ // failure a whitelist is supposed to make impossible.
79
+ const VIEW_PUBLIC = 'public';
80
+ const VIEW_SLIVER = 'sliver';
81
+ const APPLICANT_VIEWS = [VIEW_PUBLIC, VIEW_SLIVER];
82
+
83
+ // readApplicantAccount — the ONE account read behind the port: the identity band
84
+ // of an account that clears the CONSENT floor, plus the columns the branch below
85
+ // needs to decide the shape. Null for anyone who does not clear it.
86
+ //
87
+ // It selects `account_visibility` and the consent columns deliberately: the
88
+ // EXISTENCE decision is made in JS by isAccountVisible, on this row, rather than
89
+ // by a second query with a second predicate — one read, one row, one branch.
90
+ async function readApplicantAccount(githubId, { pool }) {
91
+ const { rows } = await pool.query(
92
+ `SELECT github_id, github_login, display_name, avatar_url, handle, bio, links,
93
+ created_at, profile_state, terms_accepted_at, account_visibility,
94
+ recruiter_discoverable, hide_stats
95
+ FROM platform_identity_accounts
96
+ WHERE ${accountActiveSql()} AND github_id = $1::bigint
97
+ LIMIT 1`,
98
+ [String(githubId)],
99
+ );
100
+ return rows[0] || null;
101
+ }
102
+
103
+ // publicApplicantView(account, { pool }) — the PUBLIC branch, assembled from the
104
+ // same two pieces GET /profiles/:handle serves: the identity band off the row,
105
+ // and the cross-project rollup off getPublicProfileExtras — which returns null
106
+ // for a `hide_stats` account, so hiding is honoured by the port rather than by a
107
+ // rule restated here.
108
+ //
109
+ // github_id and profile_state never cross, exactly as on the public page: the
110
+ // join key stays internal and the answer itself is the addressability signal.
111
+ async function publicApplicantView(account, { pool }) {
112
+ return {
113
+ view: VIEW_PUBLIC,
114
+ account: {
115
+ handle: account.handle ?? null,
116
+ github_login: account.github_login,
117
+ display_name: account.display_name ?? null,
118
+ avatar_url: account.avatar_url ?? null,
119
+ member_since: account.created_at,
120
+ // RENDER CONTRACT, inherited verbatim from getPublicProfileByHandle:
121
+ // escape the bio, never linkify it, and emit links with
122
+ // rel="nofollow ugc noopener". Validated at write; still user content.
123
+ bio: account.bio ?? null,
124
+ links: account.links ?? null,
125
+ },
126
+ cross_project: await pi.getPublicProfileExtras(account.github_id, { pool }),
127
+ };
128
+ }
129
+
130
+ // getApplicantProfileView(githubId, { pool }) — THE port D7 names.
131
+ //
132
+ // Answers null | { view:'public', account, cross_project } | { view:'sliver',
133
+ // sliver }. A non-numeric principal (core's 'system:bfg' shape) answers null
134
+ // rather than throwing a bigint cast mid-request, like every other read in this
135
+ // module.
136
+ //
137
+ // FAIL-CLOSED IN ONE DIRECTION ONLY: everything that is not disclosable is the
138
+ // SAME null — absent account, provisional account, terms never accepted, and an
139
+ // unusable principal are indistinguishable to a caller, which is the ADR 0171 D4
140
+ // rule. The caller renders "no record", never a reason.
141
+ async function getApplicantProfileView(githubId, { pool } = {}) {
142
+ if (!pi.isNumericGithubId(githubId)) return null;
143
+ const account = await readApplicantAccount(githubId, { pool });
144
+ if (!account) return null;
145
+ if (isAccountVisible(account)) return publicApplicantView(account, { pool });
146
+ // PRIVATE: the D3 projection, built by the sliver's own code. sliverShapeFor
147
+ // takes the row this function already holds and applies the `hide_stats`
148
+ // shape rule — the CONSENT predicate is not re-applied there, which is why
149
+ // the floor above is this function's own responsibility and is not optional.
150
+ return { view: VIEW_SLIVER, sliver: await recruiterSliver.sliverShapeFor(account, { pool }) };
151
+ }
152
+
153
+ module.exports = {
154
+ APPLICANT_VIEWS,
155
+ VIEW_PUBLIC,
156
+ VIEW_SLIVER,
157
+ getApplicantProfileView,
158
+ };
@@ -88,6 +88,55 @@ async function listRecentApplications(githubId, { pool = defaultPool } = {}) {
88
88
  return rows;
89
89
  }
90
90
 
91
+ // applicationBacksProfileRead — "did THIS account apply to THIS project, inside
92
+ // the window?" The gate under POST /sso/applicant-profile (task 1002972, D7),
93
+ // and the exact analogue of membershipBacksRollup: a boolean, nothing more.
94
+ //
95
+ // WHY IT HAS TO EXIST, and it is the whole security argument for that route.
96
+ // Client credentials say WHICH PROJECT is calling; they say NOTHING about who
97
+ // the github_id in the body is (ADR 0205, security report 1000027 — the lesson
98
+ // /sso/membership/check-in and /sso/activity/rollup both had to learn). Without
99
+ // this check, any registered project holding only its own secret could walk the
100
+ // github_id space and pull an applicant view for every account on the platform,
101
+ // PRIVATE ONES INCLUDED — D7's private branch would become a bulk disclosure
102
+ // oracle, which is strictly worse than the rollup hole it mirrors. The echo is
103
+ // the right witness precisely because the HUB writes it, in its own join relay
104
+ // (routes/my-projects.js), only after the project answered the relayed
105
+ // application with a success. A client cannot assert itself into this table.
106
+ //
107
+ // THIS IS THE ONE CROSS-ACCOUNT READ OF THIS TABLE, and it is deliberately the
108
+ // narrowest possible one — the caller must already name both the account and the
109
+ // project, and gets back a yes/no. It cannot enumerate: there is no listing by
110
+ // client_id here, so a project learns nothing about who applied that it was not
111
+ // already told when the application arrived at its own door. (The header's
112
+ // "own-scope only" line describes listRecentApplications, which is still the
113
+ // only surface that returns a LIST.)
114
+ //
115
+ // THE WINDOW IS THE SAME 30 DAYS, and reusing it is load-bearing rather than
116
+ // tidy: the echo is what makes an applicant view readable, so when the echo
117
+ // stops matching, the view stops being readable. A queue row older than the
118
+ // window renders no record — which is the correct answer, because the platform
119
+ // stopped vouching for the connection between that row and that account.
120
+ //
121
+ // Fail-closed: a non-numeric principal, an empty client, and no row are the same
122
+ // false. Never authority (ADR 0016) — a true here buys the caller a DESCRIPTION,
123
+ // and admission still reads status='invited' alone.
124
+ async function applicationBacksProfileRead({ githubId, clientId }, { pool = defaultPool } = {}) {
125
+ if (!isNumericGithubId(githubId)) return false;
126
+ const client = String(clientId || '');
127
+ if (!client) return false;
128
+ const { rows } = await pool.query(
129
+ `SELECT 1
130
+ FROM platform_identity_application_echoes
131
+ WHERE github_id = $1::bigint
132
+ AND client_id = $2
133
+ AND applied_at > now() - make_interval(days => $3::int)
134
+ LIMIT 1`,
135
+ [String(githubId), client, APPLICATION_ECHO_WINDOW_DAYS],
136
+ );
137
+ return rows.length > 0;
138
+ }
139
+
91
140
  // forgetApplication — drop this account's echo for one project. Called when the
92
141
  // account LEAVES the project: the echo means "I asked to join and have not yet",
93
142
  // and someone who joined and left has neither. Without it, leaving RESURRECTS the
@@ -111,5 +160,6 @@ module.exports = {
111
160
  APPLICATION_ECHO_WINDOW_DAYS,
112
161
  recordApplication,
113
162
  listRecentApplications,
163
+ applicationBacksProfileRead,
114
164
  forgetApplication,
115
165
  };
@@ -159,6 +159,22 @@ function projectSliver(account, activity) {
159
159
  // shape, and for a hidden account DO NOT ISSUE the activity read at all. Not
160
160
  // merely dropping the fields after fetching them is the point — the cheapest way
161
161
  // for a future edit to leak a number is for the number to already be in hand.
162
+ //
163
+ // EXPORTED as `sliverShapeFor` for ONE other caller — ./applicant-profile.js,
164
+ // the D7 rule (task 1002972) — and the export carries a contract worth stating,
165
+ // because it is the half of this file that does NOT decide consent:
166
+ //
167
+ // THIS FUNCTION APPLIES NO CONSENT PREDICATE. It takes a row somebody else
168
+ // already decided may be disclosed, and answers only "what SHAPE of sliver
169
+ // does this row get" — the `hide_stats` rule and the whitelist. The two entry
170
+ // points below reach it through readSliverAccount, whose WHERE carries
171
+ // recruiterSliverVisibleSql; a caller that does not go through them owes the
172
+ // floor itself.
173
+ //
174
+ // It is shared rather than copied for the reason D7 exists: an applicant view
175
+ // that built its own five-key object would be a SECOND pre-accept projection,
176
+ // free to drift from this one field by field, and the whitelist proof over this
177
+ // file would not cover it. One projection, two consent gates above it.
162
178
  async function sliverFor(account, { pool }) {
163
179
  if (!account) return null;
164
180
  if (account.hide_stats) return projectSliver(account, null);
@@ -194,4 +210,7 @@ module.exports = {
194
210
  ACTIVE_WINDOW_DAYS,
195
211
  getRecruiterSliver,
196
212
  getRecruiterSliverByHandle,
213
+ // The SHAPE without the consent gate — see the contract on sliverFor above.
214
+ // One caller: ./applicant-profile.js, which supplies its own floor.
215
+ sliverShapeFor: sliverFor,
197
216
  };
@@ -23,6 +23,61 @@ const canonicalProfile = require('../canonical-profile');
23
23
  const inviteSuggestions = require('../invite-suggestions');
24
24
  const hubKeys = require('../hub-keys');
25
25
  const hubDevice = require('../hub-device');
26
+ // The D7 applicant-profile read (task 1002972) and the echo that gates it.
27
+ const applicantProfile = require('../applicant-profile');
28
+ const applicationEcho = require('../application-echo');
29
+
30
+ // Per-IP cap for POST /sso/applicant-profile — DEFENCE IN DEPTH behind the echo
31
+ // gate, not the gate itself (task 1002972 grade advisory). The echo already
32
+ // closes the enumeration oracle: a caller can only read someone who applied to
33
+ // it, which it already knows about. What a ceiling adds is a bound on the cost
34
+ // of GUESSING — an attacker holding a stolen client secret, or simply a buggy
35
+ // instance, cannot walk the github_id space at line rate against the hub's
36
+ // database. The route's siblings (/sso/membership/check-in, /sso/activity/rollup)
37
+ // carry none; this one is a READ of another person's profile, which is why it
38
+ // gets the treatment GET /access-requests/status was retrofitted with.
39
+ //
40
+ // Vendored rather than shared: `accountExistenceReadRateLimit` is a per-IP budget
41
+ // deliberately SHARED between core's admission-status and the onboarding probe so
42
+ // the two cannot be alternated. This is a different audience — a federated
43
+ // instance's server, not a browser — and folding it into that budget would let a
44
+ // busy legitimate project exhaust the door a stranger uses to check their own
45
+ // admission. `perIpSlidingWindow` is not on the doorway, so this mirrors
46
+ // public-profile.js's vendored window instead.
47
+ //
48
+ // Sized for server-to-server: a reviewer render resolves at most 25 applicants
49
+ // (MAX_LIVE_PROFILE_READS), so 300/60s leaves a busy project ample headroom while
50
+ // still bounding a scripted walk.
51
+ const APPLICANT_READ_WINDOW_MS = 60 * 1000;
52
+ const APPLICANT_READ_LIMIT = 300;
53
+ const applicantReadBuckets = new Map(); // ip -> timestamp[]
54
+
55
+ const applicantSweep = setInterval(() => {
56
+ const cutoff = Date.now() - APPLICANT_READ_WINDOW_MS;
57
+ for (const [k, arr] of applicantReadBuckets) {
58
+ while (arr.length && arr[0] < cutoff) arr.shift();
59
+ if (arr.length === 0) applicantReadBuckets.delete(k);
60
+ }
61
+ }, 5 * 60 * 1000);
62
+ if (typeof applicantSweep.unref === 'function') applicantSweep.unref();
63
+
64
+ function applicantReadRateLimit(req, res, next) {
65
+ const now = Date.now();
66
+ const key = req.ip || req.socket?.remoteAddress || 'unknown';
67
+ let bucket = applicantReadBuckets.get(key);
68
+ if (!bucket) { bucket = []; applicantReadBuckets.set(key, bucket); }
69
+ const cutoff = now - APPLICANT_READ_WINDOW_MS;
70
+ while (bucket.length && bucket[0] < cutoff) bucket.shift();
71
+ if (bucket.length >= APPLICANT_READ_LIMIT) {
72
+ const retryAfter = Math.max(1, Math.ceil((bucket[0] + APPLICANT_READ_WINDOW_MS - now) / 1000));
73
+ res.set('Retry-After', String(retryAfter));
74
+ res.set('X-RateLimit-Scope', 'applicant-profile-read');
75
+ res.set('X-RateLimit-Limit', String(APPLICANT_READ_LIMIT));
76
+ return res.fail('rate_limited', 429, { scope: 'applicant-profile-read', retry_after_seconds: retryAfter });
77
+ }
78
+ bucket.push(now);
79
+ return next();
80
+ }
26
81
  // task 1003208: structured logging (pino via the doorway) — was console.*.
27
82
  const log = api.logger('platform-identity');
28
83
 
@@ -549,5 +604,76 @@ module.exports = function ssoRoutes() {
549
604
  }
550
605
  });
551
606
 
607
+ // POST /sso/applicant-profile — a federated instance resolves ONE applicant's
608
+ // profile view for its own reviewer queue, LIVE at render (privacy spec D7,
609
+ // task 1002972). Server-to-server, client_id+client_secret auth, like the two
610
+ // routes above. Answers { profile: null | { view:'public', … } | { view:
611
+ // 'sliver', … } } — the D7 rule, decided entirely inside
612
+ // ../applicant-profile.js.
613
+ //
614
+ // WHY A POST FOR A READ. The same reason /sso/activity/rollup is one: the
615
+ // credentials are in the body, and a client_secret in a query string lands in
616
+ // access logs and proxy history. Nothing is written.
617
+ //
618
+ // THE ECHO IS THE GATE, AND IT IS THE WHOLE SECURITY ARGUMENT (ADR 0205,
619
+ // security report 1000027). Client credentials prove WHICH PROJECT is calling
620
+ // and nothing about WHO the github_id is, so authenticating the caller is not
621
+ // enough: without a per-subject witness this route is a bulk disclosure oracle
622
+ // over every account on the platform, private ones included, readable by any
623
+ // registered project holding only its own secret. applicationBacksProfileRead
624
+ // demands a HUB-WRITTEN echo for exactly (this account, this client) inside
625
+ // the 30-day window — a row only the hub's own join relay creates, and only
626
+ // after the project accepted the relayed application. A project may therefore
627
+ // read the profile of someone who applied TO IT, and of nobody else.
628
+ //
629
+ // 404, never 403, when the echo is absent — the /scouting/:handle posture. A
630
+ // 403 would confirm that the github_id names a real account, which is the
631
+ // disclosure the whole rule exists to withhold. An account that exists but
632
+ // discloses nothing (provisional, terms never accepted) answers the SAME
633
+ // { profile: null } as one that consented but is simply not readable, so the
634
+ // caller cannot tell those apart either (ADR 0171 D4).
635
+ //
636
+ // NOT AUTHORITY (ADR 0016). The answer is descriptive; the project's own
637
+ // admission gate reads status='invited' and never this.
638
+ //
639
+ // rank: public — authenticated by client_id + client_secret, not a builder rank.
640
+ // The limiter runs BEFORE the client lookup, so a refused probe costs no DB
641
+ // work — the accountExistenceReadRateLimit posture.
642
+ router.post('/sso/applicant-profile', applicantReadRateLimit, async (req, res) => {
643
+ if (validateOrRespond(req, res, {
644
+ client_id: { required: true, type: 'string', maxLength: 200 },
645
+ client_secret: { required: true, type: 'string', maxLength: 512 },
646
+ github_id: { required: true },
647
+ })) return;
648
+ const b = req.body || {};
649
+ try {
650
+ const client = await pi.getClient(b.client_id, { pool });
651
+ if (!client || client.status !== 'active' || !pi.clientSecretValid(client, b.client_secret)) {
652
+ return res.fail('invalid_client', 401);
653
+ }
654
+ // Rejected before the bigint column, like every sibling route here.
655
+ if (!pi.isNumericGithubId(b.github_id)) {
656
+ return res.fail('invalid_github_id', 400);
657
+ }
658
+ // The witness FIRST — no disclosable data is read until the caller has
659
+ // proved this person applied to it. The client_id is the AUTHENTICATED
660
+ // one, never the body's, for the reason every route here takes it that way.
661
+ const applied = await applicationEcho.applicationBacksProfileRead(
662
+ { githubId: b.github_id, clientId: client.client_id }, { pool },
663
+ );
664
+ if (!applied) return res.fail('no_application', 404);
665
+ const profile = await applicantProfile.getApplicantProfileView(b.github_id, { pool });
666
+ // The rollup half is live-derived and hide must beat any cache (ADR 0171
667
+ // D5) — and D7's "live at review time" says the same thing louder: a
668
+ // cached applicant view is a snapshot, which is the one thing the rule
669
+ // forbids.
670
+ res.set('Cache-Control', 'no-store');
671
+ res.json({ profile });
672
+ } catch (err) {
673
+ log.error('[platform-identity] /sso/applicant-profile', err);
674
+ res.fail('applicant_profile_failed', { status: 500, message: 'internal error' });
675
+ }
676
+ });
677
+
552
678
  return router;
553
679
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.579",
3
+ "version": "1.19.580",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.579",
9
+ "version": "1.19.580",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.579",
3
+ "version": "1.19.580",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -394,9 +394,70 @@ async function safeSyncRankRole(builder) {
394
394
  }
395
395
 
396
396
 
397
+ // readApplicantProfileFromHub(githubId) — the reviewer queue's LIVE read of one
398
+ // applicant's profile view from the hub (privacy spec D7, task 1002972). The
399
+ // project side of POST /sso/applicant-profile.
400
+ //
401
+ // WHY IT LIVES IN THE KERNEL AND NOT IN THE onboarding MODULE THAT USES IT. The
402
+ // call is authenticated with this instance's HUB CLIENT SECRET, and
403
+ // loadIdpConfig is deliberately not on the doorway — putting it there would hand
404
+ // every module the credential that speaks for this whole project. So the secret
405
+ // stays in core beside the two other hub calls that hold it
406
+ // (reportMembershipCheckIn, reportActivityRollup) and the module reaches a
407
+ // NARROW port instead: one github_id in, one already-bounded view out.
408
+ //
409
+ // RESOLVED FRESH ON EVERY CALL — no cache, no memo, no store. That is D7's
410
+ // "evaluated LIVE at review time, never a snapshot" (spec C2), and the reason a
411
+ // hub outage answers null rather than a stale-but-helpful last-known value: an
412
+ // applicant who hid their stats an hour ago must not have them rendered from
413
+ // this instance's memory. The hub already sends Cache-Control: no-store; this
414
+ // end simply keeps nothing.
415
+ //
416
+ // FAILS SOFT TO null, ALWAYS. A self-hosted instance with no hub (idp unset), a
417
+ // hub that is down, a 404 for an applicant this project has no echo for, a
418
+ // non-JSON body — every one of them is the same null, and the queue renders "no
419
+ // record" beside a row that still shows its login, note and vouch pill. A
420
+ // reviewer losing the profile block is a degraded queue; a queue that fails to
421
+ // load because the hub is slow is a broken one, and admission does not depend on
422
+ // any of this (ADR 0016).
423
+ //
424
+ // BOUNDED IN TIME, which is what makes the paragraph above true rather than
425
+ // aspirational (task 1003681, grader finding). This is the only hub call that
426
+ // FANS OUT: the other two fire at most once per sign-in or per ship, while this
427
+ // one runs up to MAX_LIVE_PROFILE_READS (25) times concurrently inside the
428
+ // Promise.all that GET /access-requests awaits. Unbounded, one unresponsive hub
429
+ // holds the whole reviewer queue open for as long as the socket lives — the
430
+ // exact "broken one" this comment disclaims. The timeout converts that into the
431
+ // null this function already promises on every other failure.
432
+ const APPLICANT_PROFILE_TIMEOUT_MS = 5000;
433
+
434
+ async function readApplicantProfileFromHub(githubId) {
435
+ const idp = loadIdpConfig();
436
+ if (!idp || githubId == null) return null;
437
+ try {
438
+ const r = await fetch(`${idp.origin}/api/gds/sso/applicant-profile`, {
439
+ method: 'POST',
440
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
441
+ signal: AbortSignal.timeout(APPLICANT_PROFILE_TIMEOUT_MS),
442
+ body: JSON.stringify({
443
+ client_id: idp.clientId,
444
+ client_secret: idp.clientSecret,
445
+ github_id: githubId,
446
+ }),
447
+ });
448
+ if (!r.ok) return null;
449
+ const body = await r.json().catch(() => null);
450
+ return (body && body.profile) || null;
451
+ } catch (err) {
452
+ log.info('[auth] hub applicant-profile read failed (non-blocking):', err.message);
453
+ return null;
454
+ }
455
+ }
456
+
397
457
  module.exports = {
398
458
  firstAdminLogin,
399
459
  firstTimeAdmissionBlocked,
460
+ readApplicantProfileFromHub,
400
461
  // Re-exported, not defined here (task 1003579 moved it to project-door.js with
401
462
  // the two knobs it composes against). auth.js, the doorway's api.joinabilityMode
402
463
  // and four test files import it by this path, and the read-back it answers —
@@ -55,7 +55,7 @@ const ic = require('../instance-config');
55
55
  const { branding, userAgent } = require('../branding');
56
56
 
57
57
  const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
58
- const { firstAdminLogin, firstTimeAdmissionBlocked, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
58
+ const { firstAdminLogin, firstTimeAdmissionBlocked, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
59
59
  const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
60
60
  const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
61
61
  const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
@@ -787,6 +787,7 @@ module.exports = {
787
787
  idpConfigured,
788
788
  openEnrollmentEnabled,
789
789
  joinabilityMode,
790
+ readApplicantProfileFromHub,
790
791
  firstTimeAdmissionBlocked,
791
792
  webAdmissionStatus,
792
793
  firstAdminLogin,
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.579'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.580'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -273,6 +273,16 @@ module.exports = {
273
273
  // the other two had closed. Optional `{ publishable }` for a caller that
274
274
  // holds a publish verdict — see src/bongos/project-door.js.
275
275
  projectJoinDoor: (opts) => require('./bongos/project-door').effectiveJoinDoor(opts),
276
+ // readApplicantProfileFromHub(githubId) — the reviewer queue's LIVE per-render
277
+ // read of one applicant's hub profile view (privacy spec D7, task
278
+ // 1002972). Published as a NARROW port on purpose: the underlying call is
279
+ // authenticated with this instance's hub client secret, and loadIdpConfig
280
+ // stays off the doorway so no module ever holds the credential that speaks
281
+ // for the whole project. A module passes one github_id and gets one
282
+ // already-bounded view (or null) — it can neither widen the request nor
283
+ // re-address it. Fails soft to null on every error, so a hub outage costs
284
+ // the profile block and never the queue.
285
+ get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
276
286
  // userAgent — the instance's HTTP User-Agent string (branding-derived). The
277
287
  // carved lifecycle module's github-push.js stamps every GitHub API request with
278
288
  // it instead of reaching into src/branding.js directly (BV1.R86).