@bongos/core 1.19.579 → 1.19.581
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 +75 -40
- package/bin/bongos.js +10 -1
- 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/0260-the-application-is-the-consent-and-the-echo-is-the-gate.md +146 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +73 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +21 -18
- package/docs/copy-registry.json +50 -23
- package/docs/file-map.md +2 -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/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/build-cli-package.js +8 -2
- package/scripts/gds/cli-lib.js +11 -3
- package/scripts/gds/recall.js +1 -1
- package/scripts/gds/start.js +39 -26
- package/scripts/gds/surface.js +89 -0
- 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/cli_surface.mjs +146 -0
- package/tests/module_api.mjs +1 -0
- package/tests/recruiter_sliver_boundary.mjs +35 -2
- package/tests/watch_applications_queue.mjs +94 -0
|
@@ -350,6 +350,77 @@
|
|
|
350
350
|
: '<span class="fact-pill fact-pill--warn" title="Nobody vouched for this name — anyone can file a request naming anyone. Confirm who is asking before you admit them.">Name unvouched</span>';
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// ---- the applicant's own profile, beside their ask (task 1002972, D7) -----
|
|
354
|
+
//
|
|
355
|
+
// The reviewer sees what the APPLICANT'S OWN privacy rules would show them —
|
|
356
|
+
// resolved live by the server on every render, never stored on the request. So
|
|
357
|
+
// this renderer decides nothing about disclosure: it draws whichever of the
|
|
358
|
+
// two shapes the server sent, and draws nothing at all when it sent null. A
|
|
359
|
+
// null covers "not vouched", "no hub", "hub unreachable", "account hidden" and
|
|
360
|
+
// "no such account" alike, and it must stay that way on screen too — the row
|
|
361
|
+
// simply carries no profile block, and the vouch pill above already says
|
|
362
|
+
// whether anyone confirmed the name.
|
|
363
|
+
//
|
|
364
|
+
// NOTHING HERE IS AUTHORITY (ADR 0016). A rich record is not a reason to admit
|
|
365
|
+
// and an empty one is not a reason to refuse; the two buttons are unchanged
|
|
366
|
+
// and the server's admission gate never reads any of this.
|
|
367
|
+
|
|
368
|
+
// The bio, under the applicant's name. ESCAPED AND NEVER LINKIFIED — the
|
|
369
|
+
// render contract the hub's own profile payload carries, because a bio is user
|
|
370
|
+
// content on a page an Archon reads. escapeHtml is the whole defence; do not
|
|
371
|
+
// "improve" this into anchor-building.
|
|
372
|
+
function profileBio(p) {
|
|
373
|
+
var bio = p.account && p.account.bio;
|
|
374
|
+
return bio ? '<p class="ov-row__sub">' + escapeHtml(bio) + '</p>' : '';
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Disciplines as pills — the one field both shapes carry, so it is read off
|
|
378
|
+
// whichever half is present rather than branched on twice.
|
|
379
|
+
function disciplinePills(list) {
|
|
380
|
+
if (!Array.isArray(list) || !list.length) return '';
|
|
381
|
+
return list.slice(0, 6).map(function (d) {
|
|
382
|
+
return '<span class="fact-pill fact-pill--quiet">' + escapeHtml(d) + '</span>';
|
|
383
|
+
}).join('');
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// The PUBLIC shape: the cross-project rollup as one plain sentence. A null
|
|
387
|
+
// rollup is not an error and not an absence of the person — it is the builder
|
|
388
|
+
// having hidden their numbers, and saying so is what stops a reviewer reading
|
|
389
|
+
// the blank as "this applicant has done nothing".
|
|
390
|
+
function publicProfileFacts(p) {
|
|
391
|
+
var x = p.cross_project;
|
|
392
|
+
if (!x) {
|
|
393
|
+
return '<span class="ov-row__when">This builder hides their cross-project record.</span>';
|
|
394
|
+
}
|
|
395
|
+
var t = x.totals || {};
|
|
396
|
+
var parts = [];
|
|
397
|
+
if (num(t.projects)) parts.push(fmtInt(t.projects) + (t.projects === 1 ? ' project' : ' projects'));
|
|
398
|
+
if (num(t.works_shipped)) parts.push(fmtInt(t.works_shipped) + ' shipped');
|
|
399
|
+
if (num(t.credits)) parts.push(fmtCompact(t.credits) + ' credits');
|
|
400
|
+
return disciplinePills(x.disciplines)
|
|
401
|
+
+ (parts.length ? '<span class="ov-row__when">' + escapeHtml(parts.join(' · ')) + '</span>' : '')
|
|
402
|
+
+ (x.last_active ? '<span class="ov-row__when">Last active ' + escapeHtml(fmtDate(x.last_active)) + '</span>' : '');
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// The SLIVER shape: a private account that chose to apply anyway. The sentence
|
|
406
|
+
// is there so a reviewer does not read the thinness as a poor record — it is a
|
|
407
|
+
// deliberate boundary, and applying is what opened even this much.
|
|
408
|
+
function sliverProfileFacts(p) {
|
|
409
|
+
var s = p.sliver || {};
|
|
410
|
+
return disciplinePills(s.disciplines)
|
|
411
|
+
+ (s.active_recently ? '<span class="fact-pill fact-pill--ok">Active recently</span>' : '')
|
|
412
|
+
+ '<span class="ov-row__when">Private account — this is all they share by applying.</span>';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function profileHtml(p) {
|
|
416
|
+
if (!p || (p.view !== 'public' && p.view !== 'sliver')) return '';
|
|
417
|
+
var facts = p.view === 'public' ? publicProfileFacts(p) : sliverProfileFacts(p);
|
|
418
|
+
return '<div class="ov-row__profile">'
|
|
419
|
+
+ (p.view === 'public' ? profileBio(p) : '')
|
|
420
|
+
+ (facts ? '<div class="ov-row__facts">' + facts + '</div>' : '')
|
|
421
|
+
+ '</div>';
|
|
422
|
+
}
|
|
423
|
+
|
|
353
424
|
// What this row still offers. A waiting request has two decisions; an admitted
|
|
354
425
|
// one can still be rescinded (until they sign in, after which the server
|
|
355
426
|
// refuses and says so); a declined one is terminal here — re-admitting is the
|
|
@@ -384,6 +455,10 @@
|
|
|
384
455
|
'<div class="ov-row__main">' +
|
|
385
456
|
`<span class="ov-row__title">${who}</span>` +
|
|
386
457
|
note +
|
|
458
|
+
// The applicant's own profile sits between what THEY wrote and what the
|
|
459
|
+
// SYSTEM knows — their words first, their record second, the decision
|
|
460
|
+
// facts last. Empty string when the server resolved nothing.
|
|
461
|
+
profileHtml(r.profile) +
|
|
387
462
|
'<div class="ov-row__facts">' +
|
|
388
463
|
vouchPill(!!r.vouched) +
|
|
389
464
|
`<span class="ov-row__when">Asked ${escapeHtml(fmtDate(r.created_at))}</span>` +
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "1.19.581",
|
|
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.581",
|
|
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.581",
|
|
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",
|
|
@@ -35,7 +35,7 @@ const REPO_ROOT = path.join(__dirname, '..', '..');
|
|
|
35
35
|
|
|
36
36
|
// The package's OWN version — deliberately independent of the core's. The core moves many times
|
|
37
37
|
// a day (CI auto-patch, ADR 0161); the CLI's public contract should not.
|
|
38
|
-
const PACKAGE_VERSION = '0.1.
|
|
38
|
+
const PACKAGE_VERSION = '0.1.1';
|
|
39
39
|
const PACKAGE_NAME = '@cloudbongos/cli';
|
|
40
40
|
|
|
41
41
|
// ── What the package carries ────────────────────────────────────────────────────────────────
|
|
@@ -54,6 +54,7 @@ const FILES = [
|
|
|
54
54
|
'scripts/gds/onboarding-config.js', // lazy dep of setup.js
|
|
55
55
|
'scripts/gds/install-git-hooks.js', // lazy dep of setup.js
|
|
56
56
|
'scripts/gds/preflight.js',
|
|
57
|
+
'scripts/gds/surface.js', // who the output is for: agent vs terminal (task 1003680)
|
|
57
58
|
|
|
58
59
|
// Read the board and move your own work along.
|
|
59
60
|
'scripts/gds/start.js',
|
|
@@ -327,7 +328,12 @@ function main(argv) {
|
|
|
327
328
|
|
|
328
329
|
const target = path.join(SCRIPTS_DIR, def.script);
|
|
329
330
|
const spawnArgs = [target, ...(def.prefixArgs || []), ...args.slice(1)];
|
|
330
|
-
|
|
331
|
+
// Everyone reaching this package is a person at a terminal, so say so: scripts/gds/surface.js
|
|
332
|
+
// switches off the [otb-card] model directives and the /builder-* slash commands (task 1003680).
|
|
333
|
+
const res = spawnSync(process.execPath, spawnArgs, {
|
|
334
|
+
stdio: 'inherit',
|
|
335
|
+
env: { ...process.env, BONGOS_SURFACE: 'terminal' },
|
|
336
|
+
});
|
|
331
337
|
if (res.error) {
|
|
332
338
|
process.stderr.write('bongos: could not run ' + def.script + ': ' + res.error.message + '\\n');
|
|
333
339
|
return 1;
|
package/scripts/gds/cli-lib.js
CHANGED
|
@@ -749,6 +749,14 @@ function tableTask(t) {
|
|
|
749
749
|
// Maps stage -> a concrete CLI/web action the builder can take RIGHT NOW.
|
|
750
750
|
// Stage-internal hints (STAGE_HINTS in src/bongos/onboarding-state.js) are
|
|
751
751
|
// the canonical description; the per-CLI commands are the action verbs.
|
|
752
|
+
// The nudges name an action the reader can run. Which spelling is right depends on who is
|
|
753
|
+
// reading — see scripts/gds/surface.js (task 1003680). Fail-open: a missing surface module must
|
|
754
|
+
// never break a nudge.
|
|
755
|
+
function surfaceAction(verb, arg) {
|
|
756
|
+
try { return require('./surface').action(verb, arg); }
|
|
757
|
+
catch (_) { return `/builder-${verb}${arg == null ? '' : ` ${arg}`}`; }
|
|
758
|
+
}
|
|
759
|
+
|
|
752
760
|
function nudgeActionForStage(stage) {
|
|
753
761
|
switch (stage) {
|
|
754
762
|
case 'auth_complete':
|
|
@@ -756,11 +764,11 @@ function nudgeActionForStage(stage) {
|
|
|
756
764
|
case 'pick_disciplines':
|
|
757
765
|
return `Choose the work that calls you — open the hall at ${hallBase()} and pick engineer, artist, ideator, or any mix.`;
|
|
758
766
|
case 'first_start':
|
|
759
|
-
return
|
|
767
|
+
return `Run ${surfaceAction('start')} to survey what you can claim.`;
|
|
760
768
|
case 'first_claim':
|
|
761
|
-
return
|
|
769
|
+
return `Pick a task from ${surfaceAction('start')} and claim it with ${surfaceAction('claim', 'N')}.`;
|
|
762
770
|
case 'ship_three':
|
|
763
|
-
return
|
|
771
|
+
return `Carry three tasks through with ${surfaceAction('ship')} — the third makes you a Thetes.`;
|
|
764
772
|
default:
|
|
765
773
|
return null;
|
|
766
774
|
}
|
package/scripts/gds/recall.js
CHANGED
|
@@ -45,7 +45,7 @@ function truncate(s, n) {
|
|
|
45
45
|
|
|
46
46
|
function reportError(r) {
|
|
47
47
|
if (r.status === 401) {
|
|
48
|
-
console.error(
|
|
48
|
+
console.error(`Not signed in. Run ${require('./surface').action('setup')} first — recall searches as you, so it can scope results to your rank.`);
|
|
49
49
|
} else if (r.status === 429) {
|
|
50
50
|
console.error(`Rate limited (${r.data && r.data.retry_after_seconds ? r.data.retry_after_seconds + 's' : 'slow down'}). Recall is capped per builder per minute.`);
|
|
51
51
|
} else {
|