@bongos/core 1.19.578 → 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.
- package/.bongos-core.json +68 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0255-a-public-lists-ordering-is-part-of-its-payload.md +4 -1
- package/docs/adr/0259-a-projects-departure-from-the-public-list-is-public.md +128 -0
- package/docs/adr/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +73 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +23 -21
- package/docs/copy-registry.json +51 -33
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/oversight.css +7 -0
- package/modules/hall-ui/public/watch.js +75 -0
- package/modules/onboarding/routes/access-requests.js +56 -1
- package/modules/platform-identity/applicant-profile.js +158 -0
- package/modules/platform-identity/application-echo.js +50 -0
- package/modules/platform-identity/recruiter-sliver.js +19 -0
- package/modules/platform-identity/routes/sso.js +126 -0
- package/modules/public-landing/public/projects.html +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-admission.js +61 -0
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +11 -1
- package/tests/adr_renumber_integrity.mjs +84 -0
- package/tests/applicant_profile_boundary.mjs +641 -0
- package/tests/applicant_profile_read_bounds.mjs +95 -0
- package/tests/module_api.mjs +1 -0
- package/tests/projects_hub.mjs +24 -0
- package/tests/recruiter_sliver_boundary.mjs +35 -2
- package/tests/watch_applications_queue.mjs +94 -0
|
@@ -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
|
};
|
|
@@ -825,7 +825,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
825
825
|
<div id="mDoor"><p class="finePrint">Reading the project’s settings…</p></div>
|
|
826
826
|
<details class="finePrint" id="mDoorWhat">
|
|
827
827
|
<summary>What each choice changes</summary>
|
|
828
|
-
<p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
|
|
828
|
+
<p><b>Public</b> — the project is on the map under its name, and anyone who finds it can join. <b>Private</b> — it is on the map under its name, but nobody joins on their own: people ask, and you decide. <b>Stealth</b> — the map shows only that something is there; its name, and everything else about it, stay yours, and nobody can ask to join. Switching to Stealth is itself visible: someone comparing the map before and after can tell which project went quiet. Whichever you pick, a project reaches the map only once it has a name and a description. This is separate from “Who can see it” above — that one is the door on the project’s own pages. Changing it restarts the project to pick it up.</p>
|
|
829
829
|
</details>
|
|
830
830
|
</div>
|
|
831
831
|
|
|
@@ -6589,7 +6589,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
6589
6589
|
var DOOR_OPTIONS = {
|
|
6590
6590
|
public: { label: 'Public', blurb: 'The project is on the map under its name, and anyone who finds it can join.' },
|
|
6591
6591
|
private: { label: 'Private', blurb: 'On the map under its name, but nobody joins on their own — people ask, and you decide.' },
|
|
6592
|
-
stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join.' }
|
|
6592
|
+
stealth: { label: 'Stealth', blurb: 'The map shows only that something is there. Its name, and everything else about it, stay yours — and nobody can ask to join. Switching to Stealth is itself visible: someone comparing the map before and after can tell which project went quiet.' }
|
|
6593
6593
|
};
|
|
6594
6594
|
var mDoorSettings = null; /* the platform row's last answer: { value, options } */
|
|
6595
6595
|
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
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.
|
|
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.
|
|
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 —
|
package/src/bongos/auth.js
CHANGED
|
@@ -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.
|
|
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).
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// tests/adr_renumber_integrity.mjs — a renumbered ADR must move ALL of itself
|
|
2
|
+
// (task 1003681, from a grader finding).
|
|
3
|
+
//
|
|
4
|
+
// Renumbering an ADR is not one edit, it is four, and `fitness.js` only checks
|
|
5
|
+
// two of them (numbers are unique, and the index has one row per file). This
|
|
6
|
+
// pins the other two, plus the class of stale reference that caused the finding.
|
|
7
|
+
//
|
|
8
|
+
// THE INCIDENT. Task 1002972's branch authored ADR 0241 while main landed a
|
|
9
|
+
// DIFFERENT 0241; the collision failed fitness and stranded the PR for four
|
|
10
|
+
// days. Renumbering the never-landed one to 0260 moved its filename, its
|
|
11
|
+
// heading and its index row's link — but left its own session log still saying
|
|
12
|
+
// "ADR 0241" in prose, twice. Because 0241 is a REAL, DIFFERENT, ALREADY-LANDED
|
|
13
|
+
// ADR, that is worse than a dangling link: a reader follows it and arrives,
|
|
14
|
+
// with no error, at the wrong decision. `docs-entropy.js` cannot see this — it
|
|
15
|
+
// resolves LINKS, and these were bare prose references to a number that does
|
|
16
|
+
// resolve, just to something else.
|
|
17
|
+
//
|
|
18
|
+
// What each check would have caught:
|
|
19
|
+
// • heading vs filename — a rename that forgot the `# ADR NNNN` line
|
|
20
|
+
// • index row number vs its own link target — a row renumbered IN PLACE
|
|
21
|
+
// (which is also how the row ended up sorted between 0240 and 0241)
|
|
22
|
+
// • the session log's references — the finding itself, as a regression pin
|
|
23
|
+
//
|
|
24
|
+
// Run: node --test --test-reporter=tap tests/adr_renumber_integrity.mjs
|
|
25
|
+
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const ADR_DIR = path.join(ROOT, 'docs', 'adr');
|
|
34
|
+
|
|
35
|
+
const adrFiles = fs
|
|
36
|
+
.readdirSync(ADR_DIR)
|
|
37
|
+
.filter((f) => f.endsWith('.md') && /^\d{4}-/.test(f))
|
|
38
|
+
.sort();
|
|
39
|
+
|
|
40
|
+
test('every ADR file’s heading carries its own number — a rename must move the heading too', () => {
|
|
41
|
+
assert.ok(adrFiles.length > 200, 'the ADR corpus should be substantial; a tiny count means the glob broke');
|
|
42
|
+
const wrong = [];
|
|
43
|
+
for (const f of adrFiles) {
|
|
44
|
+
const fileNum = /^(\d{4})-/.exec(f)[1];
|
|
45
|
+
const first = fs.readFileSync(path.join(ADR_DIR, f), 'utf8').split(/\r?\n/)[0];
|
|
46
|
+
// both conventions ship in this corpus: "# ADR 0259 — …" and "# 0001 — …"
|
|
47
|
+
const heading = /^#\s*(?:ADR\s*)?(\d{4})\b/.exec(first);
|
|
48
|
+
if (!heading) { wrong.push(`${f} :: heading carries no number (${first.slice(0, 60)})`); continue; }
|
|
49
|
+
if (heading[1] !== fileNum) wrong.push(`${f} :: heading says ${heading[1]}`);
|
|
50
|
+
}
|
|
51
|
+
assert.deepEqual(wrong, [], 'an ADR whose heading and filename disagree is a half-finished renumber');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('every index row’s number matches the file it links — a row renumbered in place is caught here', () => {
|
|
55
|
+
const readme = fs.readFileSync(path.join(ADR_DIR, 'README.md'), 'utf8');
|
|
56
|
+
const rows = readme.split(/\r?\n/).filter((l) => /^\|\s*\d{4}\s*\|/.test(l));
|
|
57
|
+
assert.ok(rows.length > 200, 'the index should carry a row per ADR');
|
|
58
|
+
const wrong = [];
|
|
59
|
+
for (const row of rows) {
|
|
60
|
+
const rowNum = /^\|\s*(\d{4})\s*\|/.exec(row)[1];
|
|
61
|
+
// A row's summary CITES many other ADRs, so the first `](NNNN-….md)` is
|
|
62
|
+
// usually a citation. The row's OWN target is the one closing its
|
|
63
|
+
// `[**Title**](…)` cell — i.e. the last link before the trailing category
|
|
64
|
+
// cell. Matching the first one instead reports ~60 false mismatches.
|
|
65
|
+
const link = /\]\((\d{4})-[^)]*\.md\)\s*\|[^|]*\|\s*$/.exec(row);
|
|
66
|
+
if (!link) continue; // a handful of legacy rows link elsewhere or not at all
|
|
67
|
+
if (link[1] !== rowNum) wrong.push(`row ${rowNum} links ${link[1]}-…`);
|
|
68
|
+
}
|
|
69
|
+
assert.deepEqual(wrong, [], 'a row whose number and link disagree sends every citation to the wrong ADR');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('the ADR that was renumbered out of a collision leaves no reference to its old number (task 1003681)', () => {
|
|
73
|
+
// The regression itself. 0241 is a real, different, landed ADR — so a stale
|
|
74
|
+
// reference here resolves silently to the wrong decision rather than 404ing,
|
|
75
|
+
// which is exactly why no link checker flagged it.
|
|
76
|
+
const log = path.join(ROOT, 'docs', 'session-logs', '2026-09-03-goal-1000045-applicant-profile-d7.md');
|
|
77
|
+
const src = fs.readFileSync(log, 'utf8');
|
|
78
|
+
assert.doesNotMatch(src, /ADR 0241\b/, 'this task’s ADR moved to 0260; 0241 now names the artist-gate decision');
|
|
79
|
+
assert.match(src, /ADR 0260\b/, 'the log must cite the decision it actually produced');
|
|
80
|
+
assert.ok(
|
|
81
|
+
fs.existsSync(path.join(ADR_DIR, '0260-the-application-is-the-consent-and-the-echo-is-the-gate.md')),
|
|
82
|
+
'and that ADR must exist under the number the log now cites',
|
|
83
|
+
);
|
|
84
|
+
});
|