@bongos/core 1.19.1016 → 1.19.1018
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +98 -48
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -1
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +56 -5
- package/docs/api-reference.md +7 -1
- package/docs/copy-inventory.md +5 -3
- package/docs/copy-registry.json +22 -4
- package/docs/file-map.md +1 -1
- package/docs/module-api-changelog.md +9 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/hall-ui/public/people.html +11 -0
- package/modules/hall-ui/public/people.js +77 -11
- package/modules/hall-ui/public/people.states.json +1 -1
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/account-visibility.js +33 -2
- package/modules/platform-identity/builder-search.js +153 -0
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/routes/community-search.js +120 -0
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/modules/platform-identity/routes/scouting.js +14 -7
- package/modules/platform-identity/scouting-name-only.js +47 -0
- package/modules/public-landing/public/projects-floor.states.json +9 -2
- package/modules/public-landing/public/projects.html +69 -4
- package/modules/ui-design/styles/expedition/mock.html +16 -3
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/module-api.js +7 -1
- package/tests/account_privacy_flags.mjs +4 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/card_apply_ui.mjs +297 -0
- package/tests/community_search_route.mjs +385 -0
- package/tests/effective_visibility_predicate.mjs +49 -1
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
- package/tests/people_name_only.mjs +262 -0
- package/tests/profile_route.mjs +23 -5
- package/tests/recruiter_sliver_boundary.mjs +3 -0
- package/tests/scouting_owner_access_and_reach.mjs +112 -2
- package/tests/ui_design_styles.mjs +13 -0
|
@@ -10,11 +10,14 @@
|
|
|
10
10
|
// ../scouting-authz.js, which carries the full argument for why reading an
|
|
11
11
|
// instance-asserted ownership claim is not the ADR 0201 D3 inversion.
|
|
12
12
|
//
|
|
13
|
-
// WHO APPEARS IN IT: consent is enforced
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
13
|
+
// WHO APPEARS IN IT: consent is enforced in the two reads themselves, by shared
|
|
14
|
+
// predicates. `builders` is listScoutingRollup over scoutingListableSql —
|
|
15
|
+
// active, publicly visible, not hidden, not opted out (ADR 0171 D2/D3 + privacy
|
|
16
|
+
// spec D1). `name_only` (ADR 0334, task 1002291) is the hidden-but-reachable
|
|
17
|
+
// builder the spec's D2 lists "name-only": login, name and avatar, via
|
|
18
|
+
// scouting-name-only.js. So the opt-out removes a builder and hide only strips
|
|
19
|
+
// their row to identity, by construction; widening WHO may read it could not
|
|
20
|
+
// widen WHAT is shown.
|
|
18
21
|
//
|
|
19
22
|
// TWO READS, TWO AUDIENCES — and the difference is the point (ADR 0215 D4, task
|
|
20
23
|
// 1002968). `GET /scouting` is the DIRECTORY: the full rollup for builders who
|
|
@@ -37,6 +40,7 @@ const api = require('../../../src/module-api');
|
|
|
37
40
|
const pi = require('../platform-identity');
|
|
38
41
|
const scoutingAuthz = require('../scouting-authz');
|
|
39
42
|
const recruiterSliver = require('../recruiter-sliver');
|
|
43
|
+
const scoutingNameOnly = require('../scouting-name-only');
|
|
40
44
|
|
|
41
45
|
const { pool, requireBuilder, parsePagination } = api;
|
|
42
46
|
|
|
@@ -77,12 +81,15 @@ module.exports = function scoutingRoutes() {
|
|
|
77
81
|
async (req, res) => {
|
|
78
82
|
const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
|
|
79
83
|
try {
|
|
80
|
-
const builders = await
|
|
84
|
+
const [builders, nameOnly] = await Promise.all([
|
|
85
|
+
pi.listScoutingRollup({ limit, pool }),
|
|
86
|
+
scoutingNameOnly.listScoutingNameOnly({ limit, pool }),
|
|
87
|
+
]);
|
|
81
88
|
// Same rollup-derived data as the sliver, so the same rule: hide and the
|
|
82
89
|
// recruiting opt-out must beat any cache (ADR 0171 D5). This route
|
|
83
90
|
// predates that posture and was the only rollup read still missing it.
|
|
84
91
|
res.set('Cache-Control', 'no-store');
|
|
85
|
-
res.json({ builders });
|
|
92
|
+
res.json({ builders, name_only: nameOnly });
|
|
86
93
|
} catch (err) {
|
|
87
94
|
log.error(`GET /scouting failed: ${err && err.message}`);
|
|
88
95
|
res.fail('scouting_failed', { status: 500, message: 'internal error' });
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// modules/platform-identity/scouting-name-only.js — the recruiting directory's
|
|
2
|
+
// NAME-ONLY tier: builders who hid their cross-project record but are still open
|
|
3
|
+
// to recruiting (ADR 0334, task 1002291 / R15; privacy spec D2, whose recruiter
|
|
4
|
+
// column lists a hidden builder "name-only (if not opted out)").
|
|
5
|
+
//
|
|
6
|
+
// WHAT IT RETURNS. For each such account: its GitHub login, display name and
|
|
7
|
+
// avatar — what that builder's own public profile page already shows — and
|
|
8
|
+
// nothing else. No id, no count, no date, no craft: every one of those is
|
|
9
|
+
// derived from the record the builder hid. The projection is written out key by
|
|
10
|
+
// key, so a column added to the statement later cannot ride into a response.
|
|
11
|
+
//
|
|
12
|
+
// WHO IS IN IT is scoutingNameOnlySql (account-visibility.js): publicly visible
|
|
13
|
+
// AND hidden AND reachable. Two things it deliberately does NOT do:
|
|
14
|
+
// - consult the activity rollup. Listing only hidden builders who have built
|
|
15
|
+
// something would make presence here say so, and that is rollup-derived.
|
|
16
|
+
// - order by anything but the login. The ranked tier's order IS the record, so
|
|
17
|
+
// a hidden row ordered by it would leak its rank by position.
|
|
18
|
+
//
|
|
19
|
+
// WHO MAY READ IT is the directory's door (routes/scouting.js, ADR 0210). Not
|
|
20
|
+
// authority in either direction (ADR 0016).
|
|
21
|
+
//
|
|
22
|
+
// Its own file because platform-identity.js sits at the 1,500-line ratchet.
|
|
23
|
+
// Proof: tests/scouting_owner_access_and_reach.mjs §2b; the one-reader pin is in
|
|
24
|
+
// tests/effective_visibility_predicate.mjs.
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
const { scoutingNameOnlySql } = require('./account-visibility');
|
|
28
|
+
|
|
29
|
+
// listScoutingNameOnly({ limit, pool }) — the name-only tier, alphabetical by
|
|
30
|
+
// login, bounded by the same `limit` the ranked tier takes.
|
|
31
|
+
async function listScoutingNameOnly({ limit, pool }) {
|
|
32
|
+
const { rows } = await pool.query(
|
|
33
|
+
`SELECT github_login, display_name, avatar_url
|
|
34
|
+
FROM platform_identity_accounts
|
|
35
|
+
WHERE ${scoutingNameOnlySql()}
|
|
36
|
+
ORDER BY lower(github_login), github_login
|
|
37
|
+
LIMIT $1`,
|
|
38
|
+
[limit],
|
|
39
|
+
);
|
|
40
|
+
return rows.map((r) => ({
|
|
41
|
+
github_login: r.github_login ?? null,
|
|
42
|
+
display_name: r.display_name ?? null,
|
|
43
|
+
avatar_url: r.avatar_url ?? null,
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { listScoutingNameOnly };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Since task 1003352 it also carries the card BACKDROP state: featured-hub gives Example World a card_backdrop, so the owner's plate can be seen standing in front of the project's own picture. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
|
|
2
|
+
"_": "The all-null floor card (task 1003301) on the hub's own catalog (task 1003345): the same page, the same stub, the catalog aliased to featured-hub.json — the landing's three plus Tidewater, a null row nobody owns. Demo, the landing catalog's floor row, is owned by the signed-in stub, and an owned orb's plain click opens its manage view, never its card. Since task 1003352 it also carries the card BACKDROP state: featured-hub gives Example World a card_backdrop, so the owner's plate can be seen standing in front of the project's own picture. Tidewater's null relationship is also the ONLY fixture row the signed-in stub neither owns nor builds on, which is exactly what the Apply button needs (R10, task 1002286) — card-floor is its one rendered state. Rendered by name: node modules/ui-design/kit/render.js --states public-landing/projects-floor.states.json",
|
|
3
3
|
"page": "/projects",
|
|
4
4
|
"surface": "public-landing",
|
|
5
5
|
"modes": [
|
|
@@ -55,7 +55,14 @@
|
|
|
55
55
|
"visible": [
|
|
56
56
|
"#cardOpen",
|
|
57
57
|
"#cardRuns",
|
|
58
|
-
"#cardListed"
|
|
58
|
+
"#cardListed",
|
|
59
|
+
"#cardApply"
|
|
60
|
+
],
|
|
61
|
+
"text": [
|
|
62
|
+
[
|
|
63
|
+
"#cardApply",
|
|
64
|
+
"Apply to join"
|
|
65
|
+
]
|
|
59
66
|
]
|
|
60
67
|
}
|
|
61
68
|
},
|
|
@@ -1649,6 +1649,7 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
1649
1649
|
}
|
|
1650
1650
|
var catalog = [];
|
|
1651
1651
|
var mineOrigins = {}; /* normalized host → 'owner' | 'builder' */
|
|
1652
|
+
var appliedOrigins = {}; /* normalized host → true; GET /my-projects' applied echo (R10, task 1002286) */
|
|
1652
1653
|
var ownedInstances = []; /* the viewer's OWN provisioning rows (never torn_down) */
|
|
1653
1654
|
var ownedByHost = {}; /* lower(domain) → instance row — the dedupe join */
|
|
1654
1655
|
/* What the platform's own entry is FOR SEARCH. It is deliberately not pushed
|
|
@@ -2166,6 +2167,63 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2166
2167
|
: '';
|
|
2167
2168
|
}
|
|
2168
2169
|
|
|
2170
|
+
/* ══ APPLY TO JOIN (R10, task 1002286) ═══════════════════════════════
|
|
2171
|
+
The card's own door onto the join relay platform-identity already runs
|
|
2172
|
+
(POST /my-projects/join, R08 task 1002284): one click, on the origin the
|
|
2173
|
+
card already has — no URL to type, unlike the separate join-by-URL field
|
|
2174
|
+
task 1003774 is adding to View · Your projects. Hidden for the platform's
|
|
2175
|
+
own card, a member (the “yours”/“you build here” chip already says so) and
|
|
2176
|
+
a signed-out visitor (there is no session to file the request under).
|
|
2177
|
+
Applied is read from the hub's own echo (GET /my-projects' `applied`,
|
|
2178
|
+
loadMemberships) and updated the moment a request lands — the echo
|
|
2179
|
+
carries no status (ADR 0201 D1.3), so Applied IS pending here; there is
|
|
2180
|
+
nothing truer to say until membership itself appears. ══ */
|
|
2181
|
+
function cardApplyHtml(p, rel) {
|
|
2182
|
+
if (p.isSelf || isMember(rel) || !me) return '';
|
|
2183
|
+
return appliedOrigins[hostOf(p.origin)]
|
|
2184
|
+
? '<button type="button" class="btn ghost" id="cardApply" disabled>Applied</button>'
|
|
2185
|
+
: '<button type="button" class="btn ghost" id="cardApply">Apply to join</button>';
|
|
2186
|
+
}
|
|
2187
|
+
/* The relay's own outcome (my-projects.js RELAY_SENTENCE) is shown verbatim
|
|
2188
|
+
— this page invents no wording of its own for a door it did not build.
|
|
2189
|
+
Only `status === 'requested'` moves the button to Applied; every other
|
|
2190
|
+
2xx (already_member, invite_only, open_enrollment, members_only,
|
|
2191
|
+
not_published) is the door's answer, not a click that went through. */
|
|
2192
|
+
function submitApply(btn, p) {
|
|
2193
|
+
btn.disabled = true;
|
|
2194
|
+
btn.textContent = 'Applying…';
|
|
2195
|
+
var msg = $('cardApplyMsg');
|
|
2196
|
+
fetch(API + '/my-projects/join', {
|
|
2197
|
+
method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
|
|
2198
|
+
body: JSON.stringify({ origin: p.origin }),
|
|
2199
|
+
}).then(function (res) {
|
|
2200
|
+
if (res.status === 401) {
|
|
2201
|
+
btn.textContent = 'Apply to join'; btn.disabled = false;
|
|
2202
|
+
if (msg) { msg.className = 'projMsg err'; msg.innerHTML = signInAgain(); }
|
|
2203
|
+
return;
|
|
2204
|
+
}
|
|
2205
|
+
return jsonOrEmpty(res).then(function (data) {
|
|
2206
|
+
if (res.ok && data && data.status === 'requested') {
|
|
2207
|
+
appliedOrigins[hostOf(p.origin)] = true;
|
|
2208
|
+
btn.textContent = 'Applied';
|
|
2209
|
+
if (msg) { msg.className = 'projMsg ok'; msg.textContent = data.message || 'Requested.'; }
|
|
2210
|
+
return;
|
|
2211
|
+
}
|
|
2212
|
+
btn.textContent = 'Apply to join';
|
|
2213
|
+
btn.disabled = false;
|
|
2214
|
+
var e = apiError(data, res.status);
|
|
2215
|
+
if (msg) {
|
|
2216
|
+
msg.className = 'projMsg' + (res.ok ? '' : ' err');
|
|
2217
|
+
msg.textContent = (res.ok && data && data.message) || e.message || 'Could not reach that project — try again in a moment.';
|
|
2218
|
+
}
|
|
2219
|
+
});
|
|
2220
|
+
}).catch(function () {
|
|
2221
|
+
btn.textContent = 'Apply to join';
|
|
2222
|
+
btn.disabled = false;
|
|
2223
|
+
if (msg) { msg.className = 'projMsg err'; msg.textContent = 'Couldn’t reach the platform — check your connection and try again.'; }
|
|
2224
|
+
});
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2169
2227
|
function openCard(p, rel, returnTo) {
|
|
2170
2228
|
var href = p.isSelf ? '/' : safeHttpUrl(p.origin);
|
|
2171
2229
|
/* the art: catalog text an Archon typed, scheme-checked server-side and
|
|
@@ -2195,13 +2253,16 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2195
2253
|
is member-gated AND address-gated: a member's entry, only where the hall
|
|
2196
2254
|
is somewhere other than the site the first door already opens. */
|
|
2197
2255
|
var hall = isMember(rel) ? hallUrlOf(p) : '';
|
|
2256
|
+
var applyBtn = cardApplyHtml(p, rel);
|
|
2198
2257
|
$('cardActions').innerHTML = (href
|
|
2199
2258
|
? '<a class="cardOpen" id="cardOpen" href="' + esc(href) + '"' +
|
|
2200
2259
|
(p.isSelf ? '' : ' target="_blank" rel="noopener"') + '>' +
|
|
2201
2260
|
(p.isSelf ? 'Home →' : 'Open site →') + '</a>'
|
|
2202
2261
|
: '') + (hall
|
|
2203
2262
|
? '<a class="cardHall" id="cardHall" href="' + esc(hall) + '" target="_blank" rel="noopener">' + HALL_GLYPH + 'Builders\' hall</a>'
|
|
2204
|
-
: '');
|
|
2263
|
+
: '') + applyBtn + (applyBtn ? '<p class="projMsg" id="cardApplyMsg"></p>' : '');
|
|
2264
|
+
var applyEl = $('cardApply');
|
|
2265
|
+
if (applyEl && !applyEl.disabled) applyEl.addEventListener('click', function () { submitApply(applyEl, p); });
|
|
2205
2266
|
closeMenus();
|
|
2206
2267
|
cardReturn = returnTo || null;
|
|
2207
2268
|
$('cardScrim').hidden = false;
|
|
@@ -2446,16 +2507,20 @@ summary{min-height:24px;padding:3px 0;}
|
|
|
2446
2507
|
/* "Mine" is a MEMBERSHIP, not the catalog's advisory owner column: the
|
|
2447
2508
|
authority is platform_identity_project_memberships, whose role is
|
|
2448
2509
|
owner|builder|visitor, and GET /my-projects already returns the
|
|
2449
|
-
{ owned, building } split
|
|
2510
|
+
{ owned, building, applied } split — `applied` is the statusless echo
|
|
2511
|
+
(ADR 0201 D1.3) the card's Apply button reads back (R10, task 1002286).
|
|
2512
|
+
401 (signed out) is not an error here. */
|
|
2450
2513
|
function loadMemberships() {
|
|
2451
2514
|
return fetch(API + '/my-projects', { credentials: 'same-origin' })
|
|
2452
|
-
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [] }; })
|
|
2515
|
+
.then(function (res) { return res.ok ? res.json() : { owned: [], building: [], applied: [] }; })
|
|
2453
2516
|
.then(function (d) {
|
|
2454
2517
|
mineOrigins = {};
|
|
2518
|
+
appliedOrigins = {};
|
|
2455
2519
|
(d.owned || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'owner'; });
|
|
2456
2520
|
(d.building || []).forEach(function (m) { if (m.origin) mineOrigins[hostOf(m.origin)] = 'builder'; });
|
|
2521
|
+
(d.applied || []).forEach(function (m) { if (m.origin) appliedOrigins[hostOf(m.origin)] = true; });
|
|
2457
2522
|
})
|
|
2458
|
-
.catch(function () { mineOrigins = {}; });
|
|
2523
|
+
.catch(function () { mineOrigins = {}; appliedOrigins = {}; });
|
|
2459
2524
|
}
|
|
2460
2525
|
|
|
2461
2526
|
/* The owner-scoped half of the sky (task 1002744). The public catalog
|
|
@@ -39,9 +39,7 @@ body {
|
|
|
39
39
|
--gutter: clamp(16px, 4vw, 26px);
|
|
40
40
|
--band: clamp(40px, 7vh, 72px);
|
|
41
41
|
margin: 0;
|
|
42
|
-
background: var(--bg);
|
|
43
|
-
background-image: radial-gradient(var(--dots) .9px, transparent 1px);
|
|
44
|
-
background-size: var(--dot-pitch) var(--dot-pitch);
|
|
42
|
+
background-color: var(--bg);
|
|
45
43
|
color: var(--ink);
|
|
46
44
|
font-family: var(--font-body);
|
|
47
45
|
font-size: 16px;
|
|
@@ -101,6 +99,21 @@ body {
|
|
|
101
99
|
}
|
|
102
100
|
}
|
|
103
101
|
|
|
102
|
+
/* the halftone screen, fixed under the content. Not body's own background-image on purpose:
|
|
103
|
+
the branding pack's `backdrop` emits a `body{background:...}` rule after the page's own
|
|
104
|
+
stylesheet (serve-internal.js themeMarkup()), and that shorthand resets background-image
|
|
105
|
+
to none on any served page. A texture on body silently does not exist; a texture on a
|
|
106
|
+
layer does (the same pattern as the blueprint look's grid, task 1003633). */
|
|
107
|
+
body::before {
|
|
108
|
+
content: "";
|
|
109
|
+
position: fixed;
|
|
110
|
+
inset: 0;
|
|
111
|
+
z-index: -1;
|
|
112
|
+
pointer-events: none;
|
|
113
|
+
background-image: radial-gradient(var(--dots) .9px, transparent 1px);
|
|
114
|
+
background-size: var(--dot-pitch) var(--dot-pitch);
|
|
115
|
+
}
|
|
116
|
+
|
|
104
117
|
*, *::before, *::after { box-sizing: border-box; }
|
|
105
118
|
a { color: inherit; }
|
|
106
119
|
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1018",
|
|
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.1018",
|
|
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.1018",
|
|
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",
|
package/src/bongos/auth-idp.js
CHANGED
|
@@ -29,6 +29,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
29
29
|
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, reportActivityRollup, reportMembershipCheckIn, safeSyncRankRole } = require('./auth-admission.js');
|
|
30
30
|
const { loadIdpConfig } = require('./auth-config.js');
|
|
31
31
|
const { newSessionToken } = require('./auth-github.js');
|
|
32
|
+
const log = require('./logger').logger.child({ src: 'auth-idp' });
|
|
32
33
|
|
|
33
34
|
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
34
35
|
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/bongos/sso/authorize`);
|
|
@@ -149,32 +150,38 @@ function loadHubPublicKey() {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
// regardless of the header's `alg` field, so there is no algorithm-confusion attack.
|
|
158
|
-
// Returns the validated payload, or throws. Pure over its inputs (testable).
|
|
159
|
-
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
153
|
+
// verifyHubToken — the envelope every hub token shares (hub-keys.js signHubToken,
|
|
154
|
+
// lockstep across the boundary, ADR 0091 §2): Ed25519 signature FIRST, alg hardcoded
|
|
155
|
+
// so there is no algorithm confusion, then issuer, audience (when given) and expiry.
|
|
156
|
+
// Which KIND of token it is, each verifier below decides from the claims.
|
|
157
|
+
function verifyHubToken(token, { publicKey, clientId, nowMs = Date.now(), label }) {
|
|
160
158
|
if (!publicKey) throw new Error('hub public key not configured');
|
|
161
159
|
const parts = String(token || '').split('.');
|
|
162
|
-
if (parts.length !== 3) throw new Error(
|
|
160
|
+
if (parts.length !== 3) throw new Error(`malformed ${label}`);
|
|
163
161
|
const [h, p, s] = parts;
|
|
164
162
|
let header;
|
|
165
163
|
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
166
|
-
catch { throw new Error(
|
|
167
|
-
if (!header || header.alg !== 'EdDSA') throw new Error(
|
|
164
|
+
catch { throw new Error(`malformed ${label} header`); }
|
|
165
|
+
if (!header || header.alg !== 'EdDSA') throw new Error(`unexpected ${label} alg`);
|
|
168
166
|
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
169
167
|
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
170
|
-
if (!ok) throw new Error(
|
|
168
|
+
if (!ok) throw new Error(`${label} signature invalid`);
|
|
171
169
|
let payload;
|
|
172
170
|
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
173
|
-
catch { throw new Error(
|
|
174
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(
|
|
175
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(
|
|
176
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(
|
|
171
|
+
catch { throw new Error(`malformed ${label} payload`); }
|
|
172
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(`unexpected ${label} issuer`);
|
|
173
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(`${label} audience mismatch`);
|
|
174
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(`${label} expired`);
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verify a hub-signed LOGIN assertion: the envelope, identity, and NO `purpose` —
|
|
179
|
+
// any purpose, not a list, so no identity-bearing token kind can pass as a device
|
|
180
|
+
// sign-in (ADR 0208 D2, ADR 0333 D2).
|
|
181
|
+
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
182
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'assertion' });
|
|
177
183
|
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'purpose')) throw new Error('assertion carries a purpose — not a login assertion');
|
|
178
185
|
return payload;
|
|
179
186
|
}
|
|
180
187
|
|
|
@@ -195,41 +202,46 @@ const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
|
|
|
195
202
|
// github_login (rejected by verifyHubAssertion). Returns the validated payload
|
|
196
203
|
// ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
|
|
197
204
|
function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
198
|
-
|
|
199
|
-
const parts = String(token || '').split('.');
|
|
200
|
-
if (parts.length !== 3) throw new Error('malformed logout token');
|
|
201
|
-
const [h, p, s] = parts;
|
|
202
|
-
let header;
|
|
203
|
-
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
204
|
-
catch { throw new Error('malformed logout token header'); }
|
|
205
|
-
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
|
|
206
|
-
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
207
|
-
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
208
|
-
if (!ok) throw new Error('logout token signature invalid');
|
|
209
|
-
let payload;
|
|
210
|
-
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
211
|
-
catch { throw new Error('malformed logout token payload'); }
|
|
212
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
|
|
213
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
|
|
214
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
|
|
205
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'logout token' });
|
|
215
206
|
if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
|
|
216
207
|
if (payload.sub == null) throw new Error('logout token missing subject');
|
|
217
208
|
return payload;
|
|
218
209
|
}
|
|
219
210
|
|
|
211
|
+
// The purpose marker on a hub APPLICATION VOUCH — lockstep with hub-keys.js.
|
|
212
|
+
const HUB_APPLICATION_PURPOSE = 'application';
|
|
213
|
+
|
|
214
|
+
// Verify a hub-signed APPLICATION VOUCH (ADR 0333): purpose 'application', identity
|
|
215
|
+
// (sub + applicant_login), and — stricter than its siblings — a required audience
|
|
216
|
+
// (the envelope skips the check without one) and a jti (single use needs one).
|
|
217
|
+
function verifyHubApplicationVouch(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
218
|
+
if (clientId == null || clientId === '') throw new Error('application vouch audience not configured');
|
|
219
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'application vouch' });
|
|
220
|
+
if (payload.purpose !== HUB_APPLICATION_PURPOSE) throw new Error('not an application vouch');
|
|
221
|
+
if (payload.sub == null || !payload.applicant_login) throw new Error('application vouch missing identity');
|
|
222
|
+
if (!payload.jti) throw new Error('application vouch missing jti');
|
|
223
|
+
return payload;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// jtiStore — a single-use jti register. One per token kind, so no two kinds share
|
|
227
|
+
// dedupe state; entries expire with their token. In-memory per process.
|
|
228
|
+
function jtiStore() {
|
|
229
|
+
const seen = new Map(); // jti -> expiry ms
|
|
230
|
+
return function seenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
231
|
+
for (const [k, exp] of seen) if (exp < nowMs) seen.delete(k);
|
|
232
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
233
|
+
if (seen.has(jti)) return true;
|
|
234
|
+
seen.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
221
240
|
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
222
241
|
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
223
242
|
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
224
243
|
// short window and that the aud binding is the primary control.
|
|
225
|
-
const
|
|
226
|
-
function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
227
|
-
for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
|
|
228
|
-
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
229
|
-
if (_seenAssertionJti.has(jti)) return true;
|
|
230
|
-
_seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
244
|
+
const _assertionJtiSeenAndRecord = jtiStore();
|
|
233
245
|
|
|
234
246
|
// Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
|
|
235
247
|
// assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
|
|
@@ -241,13 +253,43 @@ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
|
241
253
|
// share dedupe state. In-memory per process; a restart clears it (acceptable — the
|
|
242
254
|
// window is short and the aud binding is the primary control). Exported for the
|
|
243
255
|
// route (routes/auth.js) since the DB revoke lives there, not in the kernel.
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
const logoutTokenJtiSeenAndRecord = jtiStore();
|
|
257
|
+
|
|
258
|
+
// Anti-replay for APPLICATION VOUCHES — chosen, not inherited (ADR 0333 D3): one
|
|
259
|
+
// vouch vouches one application.
|
|
260
|
+
const vouchJtiSeenAndRecord = jtiStore();
|
|
261
|
+
|
|
262
|
+
// The hub's handle charset (platform-identity.js normalizeHandle — boundary-forced
|
|
263
|
+
// mirror, pinned by tests/idp_device_exchange.mjs). Anything else is stored as null.
|
|
264
|
+
const HUB_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
|
|
265
|
+
|
|
266
|
+
// applicationVouchIdentity — { githubId, handle } from a verified, unspent vouch that
|
|
267
|
+
// names the request's own login, else null; never throws. Null is never a refusal
|
|
268
|
+
// (ADR 0201 D2.3): the route writes the unvouched row. Key + audience injected.
|
|
269
|
+
function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
270
|
+
if (!token || !publicKey || !clientId) return null;
|
|
271
|
+
let payload;
|
|
272
|
+
try {
|
|
273
|
+
payload = verifyHubApplicationVouch(token, { publicKey, clientId, nowMs });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log.info({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// The body's login is a string anyone may type: a vouch for another account is no vouch.
|
|
279
|
+
if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
|
|
280
|
+
if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
|
|
281
|
+
return {
|
|
282
|
+
githubId: String(payload.sub),
|
|
283
|
+
handle: typeof payload.handle === 'string' && HUB_HANDLE_RE.test(payload.handle) ? payload.handle : null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// readApplicationVouch — the doorway port: supplies THIS instance's hub key and
|
|
288
|
+
// client_id itself, so a module can neither swap the key nor re-address the check.
|
|
289
|
+
function readApplicationVouch(token, { githubLogin } = {}) {
|
|
290
|
+
if (!token) return null;
|
|
291
|
+
const idp = loadIdpConfig();
|
|
292
|
+
return applicationVouchIdentity(token, { githubLogin, publicKey: idp && loadHubPublicKey(), clientId: idp && idp.clientId });
|
|
251
293
|
}
|
|
252
294
|
|
|
253
295
|
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
@@ -318,6 +360,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
|
318
360
|
// without creds or network.
|
|
319
361
|
|
|
320
362
|
module.exports = {
|
|
363
|
+
applicationVouchIdentity,
|
|
321
364
|
buildIdpAuthorizeUrl,
|
|
322
365
|
completeDeviceExchange,
|
|
323
366
|
completeIdpFlow,
|
|
@@ -325,6 +368,8 @@ module.exports = {
|
|
|
325
368
|
idpAuthorizeUrl,
|
|
326
369
|
loadHubPublicKey,
|
|
327
370
|
logoutTokenJtiSeenAndRecord,
|
|
371
|
+
readApplicationVouch,
|
|
372
|
+
verifyHubApplicationVouch,
|
|
328
373
|
verifyHubAssertion,
|
|
329
374
|
verifyHubLogoutToken,
|
|
330
375
|
};
|
package/src/bongos/auth.js
CHANGED
|
@@ -57,7 +57,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
58
|
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
|
-
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
60
|
+
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
62
62
|
|
|
63
63
|
const MAX_AUTH_TOKEN_CANDIDATES = 6;
|
|
@@ -901,6 +901,7 @@ module.exports = {
|
|
|
901
901
|
openEnrollmentEnabled,
|
|
902
902
|
joinabilityMode,
|
|
903
903
|
readApplicantProfileFromHub,
|
|
904
|
+
readApplicationVouch,
|
|
904
905
|
firstTimeAdmissionBlocked,
|
|
905
906
|
instanceNeedsFirstAdmin,
|
|
906
907
|
webAdmissionStatus,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1018'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -346,6 +346,12 @@ module.exports = {
|
|
|
346
346
|
// re-address it. Fails soft to null on every error, so a hub outage costs
|
|
347
347
|
// the profile block and never the queue.
|
|
348
348
|
get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
|
|
349
|
+
// readApplicationVouch(token, { githubLogin }) — the write-side twin (task
|
|
350
|
+
// 1002285, ADR 0201 D2): verify the hub's application vouch on a public
|
|
351
|
+
// access request → { githubId, handle } or null. Core supplies this
|
|
352
|
+
// instance's hub key and client_id itself, so a module can neither
|
|
353
|
+
// substitute a key nor re-address the check. Null is never a refusal.
|
|
354
|
+
get readApplicationVouch() { return require('./bongos/auth').readApplicationVouch; },
|
|
349
355
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
350
356
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
351
357
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|
|
@@ -416,7 +416,10 @@ test('the recruiting surface really is exactly two reads, and each one joins REA
|
|
|
416
416
|
assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
|
|
417
417
|
|
|
418
418
|
const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
|
|
419
|
-
|
|
419
|
+
// The directory reads TWO tiers since task 1002291 — ranked, and the hidden
|
|
420
|
+
// builders it lists by name — and the opt-out must empty both, or "removes you
|
|
421
|
+
// from the People directory" is false for a builder who also hid their record.
|
|
422
|
+
for (const composed of ['scoutingListableSql', 'scoutingNameOnlySql', 'recruiterSliverVisibleSql']) {
|
|
420
423
|
const body = av.slice(av.indexOf(`function ${composed}(`));
|
|
421
424
|
assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
|
|
422
425
|
`${composed} stopped composing the reach axis — opting out would no longer empty that surface`);
|