@bongos/core 1.19.332 → 1.19.334
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 +173 -43
- package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +2 -2
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +202 -202
- package/docs/copy-registry.json +384 -384
- package/modules/grading/grader-config.js +98 -0
- package/modules/grading/grader-model.js +150 -0
- package/modules/grading/grader-prompt.js +139 -0
- package/modules/grading/grader-score.js +706 -0
- package/modules/grading/grader-subagent.js +328 -0
- package/modules/grading/grader.js +5 -1287
- package/modules/hall-ui/public/builders.js +19 -1182
- package/modules/hall-ui/public/goals-page.js +11 -997
- package/modules/hall-ui/public/goals-render.js +1032 -0
- package/modules/hall-ui/public/goals.html +4 -0
- package/modules/hall-ui/public/hall-render.js +1211 -0
- package/modules/hall-ui/public/index.html +6 -0
- package/modules/lifecycle/routes/goal-route-authz.js +400 -0
- package/modules/lifecycle/routes/goals.js +1 -367
- package/modules/lifecycle/routes/task-route-helpers.js +219 -0
- package/modules/lifecycle/routes/task-write-routes.js +542 -0
- package/modules/lifecycle/routes/tasks.js +3 -696
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/box-infra.js +280 -0
- package/scripts/gds/box.js +1 -245
- package/scripts/gds/fitness-checks-identity.js +320 -0
- package/scripts/gds/fitness-checks-vocabulary.js +230 -0
- package/scripts/gds/fitness-checks-write-validation.js +144 -0
- package/scripts/gds/fitness-lib.js +82 -0
- package/scripts/gds/fitness.js +55 -668
- package/scripts/gds/provision-config.js +204 -0
- package/scripts/gds/provision-net.js +221 -0
- package/scripts/gds/provision-repo.js +849 -0
- package/scripts/gds/provision-units.js +262 -0
- package/scripts/gds/provision.js +4 -1395
- package/src/bongos/auth-admission.js +264 -0
- package/src/bongos/auth-config.js +151 -0
- package/src/bongos/auth-discord.js +198 -0
- package/src/bongos/auth-github.js +336 -0
- package/src/bongos/auth-idp.js +330 -0
- package/src/bongos/auth.js +5 -1100
- package/src/bongos/routes/auth.js +54 -10
- package/src/module-api.js +5 -1
- package/tests/auth_admission_gate.mjs +3 -1
- package/tests/auth_web_recovery.mjs +63 -0
- package/tests/est_advisory.mjs +3 -1
- package/tests/goal_advisory.mjs +3 -2
- package/tests/goal_criterion_confirm_ui.mjs +6 -0
- package/tests/goal_subtitle.mjs +5 -2
- package/tests/goals_card_view.mjs +10 -3
- package/tests/grader_generated_provenance.mjs +3 -1
- package/tests/hall_palette.mjs +3 -1
- package/tests/helpers.mjs +58 -25
- package/tests/lib_sh_resolution.mjs +3 -1
- package/tests/membership_kind_lockstep.mjs +3 -1
- package/tests/migration_allocation.mjs +5 -2
- package/tests/patch_gate_ordering.mjs +3 -1
- package/tests/rank_tier_single_source.mjs +16 -3
- package/tests/reward_gate.mjs +3 -2
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
// src/bongos/auth-github.js — the GitHub OAuth flows: session-token minting, the
|
|
2
|
+
// user fetch, the code exchange, the CLI device flow and the web flow.
|
|
3
|
+
//
|
|
4
|
+
// Carved out of src/bongos/auth.js by task 1003206 (goal 1000079, the R03 plan)
|
|
5
|
+
// to bring the kernel identity file under the 1,500-line ratchet. This is the
|
|
6
|
+
// highest-risk file in the carve, so the move is STRICTLY structural: bodies
|
|
7
|
+
// unchanged, auth.js requires every name back, and the middleware surface
|
|
8
|
+
// (requireBuilder / requireRank / requirePermission / the page gates) never left.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const crypto = require('node:crypto');
|
|
13
|
+
const {
|
|
14
|
+
upsertBuilder,
|
|
15
|
+
seatFirstAdminIfUnclaimed,
|
|
16
|
+
createSession,
|
|
17
|
+
lookupSession,
|
|
18
|
+
getBuilderById,
|
|
19
|
+
getBuilderByGithubId,
|
|
20
|
+
hasInvitedAccessRequest,
|
|
21
|
+
accessStateByLogin,
|
|
22
|
+
RANKS,
|
|
23
|
+
} = require('./db-kernel');
|
|
24
|
+
const { pool } = require('./pool');
|
|
25
|
+
const seams = require('../module-seams');
|
|
26
|
+
const { branding, userAgent } = require('../branding');
|
|
27
|
+
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, safeSyncRankRole } = require('./auth-admission.js');
|
|
28
|
+
const { SCOPE, deviceFlows, loadGithubCreds, scopeForWebFlow } = require('./auth-config.js');
|
|
29
|
+
|
|
30
|
+
function newSessionToken() {
|
|
31
|
+
return crypto.randomBytes(32).toString('hex');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// -------------------------------------------------------------------------
|
|
35
|
+
// GitHub helpers
|
|
36
|
+
// -------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
async function fetchGithubUser(accessToken) {
|
|
39
|
+
const res = await fetch('https://api.github.com/user', {
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${accessToken}`,
|
|
42
|
+
'User-Agent': userAgent(),
|
|
43
|
+
Accept: 'application/vnd.github+json',
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
const text = await res.text();
|
|
48
|
+
throw new Error(`GitHub /user failed (${res.status}): ${text}`);
|
|
49
|
+
}
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function exchangeCodeForToken(code, redirectUri) {
|
|
54
|
+
const creds = loadGithubCreds();
|
|
55
|
+
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
56
|
+
const res = await fetch('https://github.com/login/oauth/access_token', {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: {
|
|
59
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
60
|
+
Accept: 'application/json',
|
|
61
|
+
},
|
|
62
|
+
body: new URLSearchParams({
|
|
63
|
+
client_id: creds.client_id,
|
|
64
|
+
client_secret: creds.client_secret,
|
|
65
|
+
code,
|
|
66
|
+
redirect_uri: redirectUri,
|
|
67
|
+
}).toString(),
|
|
68
|
+
});
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
|
|
71
|
+
// Return the GRANTED scope alongside the token. GitHub echoes back the scopes
|
|
72
|
+
// the user actually consented to (space/comma-separated) — the caller checks it
|
|
73
|
+
// before persisting a repo-capable token, so a user who declined the broader
|
|
74
|
+
// consent never has one stored (task 1002099 / ADR 0143).
|
|
75
|
+
return { accessToken: data.access_token, scope: data.scope || '' };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// -------------------------------------------------------------------------
|
|
79
|
+
// Device Flow (CLI)
|
|
80
|
+
// -------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async function startDeviceFlow() {
|
|
83
|
+
const creds = loadGithubCreds();
|
|
84
|
+
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
85
|
+
const res = await fetch('https://github.com/login/device/code', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
headers: {
|
|
88
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
89
|
+
Accept: 'application/json',
|
|
90
|
+
},
|
|
91
|
+
body: new URLSearchParams({ client_id: creds.client_id, scope: SCOPE }).toString(),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const text = await res.text();
|
|
95
|
+
throw new Error(`device/code failed (${res.status}): ${text}`);
|
|
96
|
+
}
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
// GitHub returns HTTP 200 with an { error, error_description } body for OAuth-App
|
|
99
|
+
// misconfig — most often device_flow_disabled (Device Flow not enabled on the App)
|
|
100
|
+
// or incorrect_client_credentials. res.ok is TRUE there, so without this the missing
|
|
101
|
+
// device_code silently produced a broken flow / an opaque 500 (the 2026-07-08 demo
|
|
102
|
+
// login failure). Mirror the token-exchange guard (data.error, above); task 2126.
|
|
103
|
+
if (data.error) {
|
|
104
|
+
// device_flow_disabled is self-inflicted + common: GitHub defaults Device Flow OFF on new
|
|
105
|
+
// Apps and the one-click manifest can't enable it (task 2129). Make the CLI error self-
|
|
106
|
+
// service by naming the right settings surface. We hold only client_id here (not the app
|
|
107
|
+
// slug — that lives on the control plane), so we can't build /settings/apps/<slug>; but the
|
|
108
|
+
// id PREFIX distinguishes a GitHub App (Iv*) from an OAuth App (Ov*), which picks the right
|
|
109
|
+
// listing to open.
|
|
110
|
+
let hint = '';
|
|
111
|
+
if (data.error === 'device_flow_disabled') {
|
|
112
|
+
const where = /^Iv/i.test(creds.client_id || '')
|
|
113
|
+
? 'https://github.com/settings/apps (org-owned apps: https://github.com/organizations/<org>/settings/apps)'
|
|
114
|
+
: 'https://github.com/settings/developers';
|
|
115
|
+
hint = ` — terminal sign-in needs Device Flow enabled on the app: open ${where}, open your app, check "Enable Device Flow", and save. Web sign-in is unaffected.`;
|
|
116
|
+
}
|
|
117
|
+
throw new Error(`GitHub rejected device-flow start: ${data.error}${data.error_description ? ` — ${data.error_description}` : ''}${hint}`);
|
|
118
|
+
}
|
|
119
|
+
if (!data.device_code) {
|
|
120
|
+
throw new Error('GitHub device/code returned no device_code (unexpected response shape).');
|
|
121
|
+
}
|
|
122
|
+
// data: { device_code, user_code, verification_uri, expires_in, interval }
|
|
123
|
+
deviceFlows.set(data.device_code, {
|
|
124
|
+
interval: data.interval || 5,
|
|
125
|
+
expires_at: Date.now() + data.expires_in * 1000,
|
|
126
|
+
last_polled_at: 0,
|
|
127
|
+
});
|
|
128
|
+
// Reap expired flows (cheap)
|
|
129
|
+
for (const [k, v] of deviceFlows) {
|
|
130
|
+
if (v.expires_at < Date.now()) deviceFlows.delete(k);
|
|
131
|
+
}
|
|
132
|
+
return {
|
|
133
|
+
device_code: data.device_code,
|
|
134
|
+
user_code: data.user_code,
|
|
135
|
+
verification_uri: data.verification_uri,
|
|
136
|
+
expires_in: data.expires_in,
|
|
137
|
+
interval: data.interval || 5,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function pollDeviceFlow(deviceCode, userAgent) {
|
|
142
|
+
const flow = deviceFlows.get(deviceCode);
|
|
143
|
+
if (!flow) return { state: 'expired' };
|
|
144
|
+
if (flow.expires_at < Date.now()) {
|
|
145
|
+
deviceFlows.delete(deviceCode);
|
|
146
|
+
return { state: 'expired' };
|
|
147
|
+
}
|
|
148
|
+
// Server-side throttling: GitHub returns slow_down if we hammer; we
|
|
149
|
+
// also enforce the interval client-to-server.
|
|
150
|
+
const now = Date.now();
|
|
151
|
+
if (now - flow.last_polled_at < flow.interval * 1000) {
|
|
152
|
+
return { state: 'slow_down', interval: flow.interval };
|
|
153
|
+
}
|
|
154
|
+
flow.last_polled_at = now;
|
|
155
|
+
|
|
156
|
+
const creds = loadGithubCreds();
|
|
157
|
+
const res = await fetch('https://github.com/login/oauth/access_token', {
|
|
158
|
+
method: 'POST',
|
|
159
|
+
headers: {
|
|
160
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
161
|
+
Accept: 'application/json',
|
|
162
|
+
},
|
|
163
|
+
body: new URLSearchParams({
|
|
164
|
+
client_id: creds.client_id,
|
|
165
|
+
device_code: deviceCode,
|
|
166
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
167
|
+
}).toString(),
|
|
168
|
+
});
|
|
169
|
+
const data = await res.json();
|
|
170
|
+
if (data.error === 'authorization_pending') return { state: 'pending' };
|
|
171
|
+
if (data.error === 'slow_down') {
|
|
172
|
+
flow.interval = (flow.interval || 5) + 5;
|
|
173
|
+
return { state: 'slow_down', interval: flow.interval };
|
|
174
|
+
}
|
|
175
|
+
if (data.error === 'expired_token') {
|
|
176
|
+
deviceFlows.delete(deviceCode);
|
|
177
|
+
return { state: 'expired' };
|
|
178
|
+
}
|
|
179
|
+
if (data.error === 'access_denied') {
|
|
180
|
+
deviceFlows.delete(deviceCode);
|
|
181
|
+
return { state: 'denied' };
|
|
182
|
+
}
|
|
183
|
+
if (data.access_token) {
|
|
184
|
+
deviceFlows.delete(deviceCode);
|
|
185
|
+
const ghUser = await fetchGithubUser(data.access_token);
|
|
186
|
+
// Admission gate (SR-19 / task #1000): mirror completeWebFlow EXACTLY. The
|
|
187
|
+
// CLI device flow used to call upsertBuilder unconditionally, so any GitHub
|
|
188
|
+
// account could self-enroll as a Xenos + mint a CLI session with no Archon
|
|
189
|
+
// approval — contradicting the "Archon admission is the only gate" rule. Now
|
|
190
|
+
// a FIRST-TIME account (no existing builder row for this github_id) is
|
|
191
|
+
// admitted only if open enrollment is on OR an Archon marked their
|
|
192
|
+
// access_request 'invited'. An EXISTING builder ALWAYS passes, so an approved
|
|
193
|
+
// builder can never be locked out of CLI enrollment. See ADR 0050.
|
|
194
|
+
const existing = await getBuilderByGithubId(ghUser.id);
|
|
195
|
+
// Only consult the access_request table for a genuine first-timer (and only
|
|
196
|
+
// when open enrollment is off) — an existing builder short-circuits to
|
|
197
|
+
// ALWAYS-pass, matching completeWebFlow's `if (!existing)` lookup.
|
|
198
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
199
|
+
const approved = await hasInvitedAccessRequest(ghUser.login);
|
|
200
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
|
|
201
|
+
const err = new Error('not_approved');
|
|
202
|
+
err.code = 'NOT_APPROVED';
|
|
203
|
+
throw err;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
let builder = await upsertBuilder({
|
|
207
|
+
githubId: ghUser.id,
|
|
208
|
+
githubLogin: ghUser.login,
|
|
209
|
+
displayName: ghUser.name || ghUser.login,
|
|
210
|
+
avatarUrl: ghUser.avatar_url,
|
|
211
|
+
});
|
|
212
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
213
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
214
|
+
const token = newSessionToken();
|
|
215
|
+
await createSession({
|
|
216
|
+
builderId: builder.id,
|
|
217
|
+
token,
|
|
218
|
+
source: 'cli',
|
|
219
|
+
userAgent: userAgent ?? null,
|
|
220
|
+
// CLI tokens have no expiry (manually revocable). Web tokens expire.
|
|
221
|
+
expiresAt: null,
|
|
222
|
+
});
|
|
223
|
+
// V3.R75 (#294): tick the onboarding state machine. Best-effort — auth
|
|
224
|
+
// success must NOT fail because the onboarding plumbing tripped.
|
|
225
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
226
|
+
return { state: 'ok', token, builder };
|
|
227
|
+
}
|
|
228
|
+
return { state: 'pending' };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// -------------------------------------------------------------------------
|
|
232
|
+
// Web Flow (browser)
|
|
233
|
+
// -------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
|
|
236
|
+
const creds = loadGithubCreds();
|
|
237
|
+
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
238
|
+
const u = new URL('https://github.com/login/oauth/authorize');
|
|
239
|
+
u.searchParams.set('client_id', creds.client_id);
|
|
240
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
241
|
+
// `repos:true` (the onboarding wizard only) requests the broader repo scope; every
|
|
242
|
+
// other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
|
|
243
|
+
u.searchParams.set('scope', scopeForWebFlow({ repos }));
|
|
244
|
+
u.searchParams.set('state', state);
|
|
245
|
+
return u.toString();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
|
|
249
|
+
const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
|
|
250
|
+
const ghUser = await fetchGithubUser(accessToken);
|
|
251
|
+
// Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
|
|
252
|
+
// with a GitHub account. A first-time visitor must have been approved by an
|
|
253
|
+
// Archon (their access_request marked 'invited') before a builder row is
|
|
254
|
+
// created. This Archon approval is the ONLY gate — newcomers build on hosted
|
|
255
|
+
// dev boxes, so there is no GitHub repo-access / collaborator check anywhere
|
|
256
|
+
// (2026-06-07). Established builders (a row already exists for this github_id)
|
|
257
|
+
// always pass — this only blocks NEW first-time entries.
|
|
258
|
+
//
|
|
259
|
+
// SR-19 / task #1000 (2026-06-13): the CLI device flow (pollDeviceFlow,
|
|
260
|
+
// /builder-setup) is now gated the SAME way — it previously called
|
|
261
|
+
// upsertBuilder unconditionally, letting any GitHub account self-enroll as a
|
|
262
|
+
// Xenos with no Archon approval. Both paths share firstTimeAdmissionBlocked()
|
|
263
|
+
// so they cannot drift. An owner who wants fully-open self-enrollment sets
|
|
264
|
+
// OTB_OPEN_XENOS_ENROLLMENT truthy (no code change); default = invite required.
|
|
265
|
+
// See ADR 0050.
|
|
266
|
+
const existing = await getBuilderByGithubId(ghUser.id);
|
|
267
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
268
|
+
const approved = await hasInvitedAccessRequest(ghUser.login);
|
|
269
|
+
// Pass `login` so the configured-firstAdmin bypass (firstTimeAdmissionBlocked
|
|
270
|
+
// L124-125) fires on WEB sign-in too — without it the founding owner is gated
|
|
271
|
+
// out of their own fresh instance at "The Gate is Closed", a chicken-and-egg
|
|
272
|
+
// since no Archon exists yet to approve them. The device flow (pollDeviceFlow,
|
|
273
|
+
// ~L305) already passes login; this keeps the two paths from drifting (SR-19).
|
|
274
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
|
|
275
|
+
const err = new Error('not_approved');
|
|
276
|
+
err.code = 'NOT_APPROVED';
|
|
277
|
+
// Carry the login so the route can tell an imminent admission (the
|
|
278
|
+
// configured firstAdmin mid-bootstrap, or someone with a pending request)
|
|
279
|
+
// from a genuine stranger, and show the "confirming access" waiting page
|
|
280
|
+
// instead of a red dead-end (task 1002097). Attaching it here — rather
|
|
281
|
+
// than re-deriving it in the route — keeps the login the gate actually
|
|
282
|
+
// judged. It is NOT a decision: the route re-checks via webAdmissionStatus.
|
|
283
|
+
err.login = ghUser.login;
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
let builder = await upsertBuilder({
|
|
288
|
+
githubId: ghUser.id,
|
|
289
|
+
githubLogin: ghUser.login,
|
|
290
|
+
displayName: ghUser.name || ghUser.login,
|
|
291
|
+
avatarUrl: ghUser.avatar_url,
|
|
292
|
+
});
|
|
293
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
294
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
295
|
+
const token = newSessionToken();
|
|
296
|
+
// Web sessions expire after 30 days; the user re-auths on the page
|
|
297
|
+
// when the cookie expires.
|
|
298
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
299
|
+
await createSession({
|
|
300
|
+
builderId: builder.id,
|
|
301
|
+
token,
|
|
302
|
+
source: 'web',
|
|
303
|
+
userAgent: userAgent ?? null,
|
|
304
|
+
expiresAt,
|
|
305
|
+
});
|
|
306
|
+
// V3.R75 (#294): tick the onboarding state machine. Best-effort.
|
|
307
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
308
|
+
// Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
|
|
309
|
+
// repo access, hand the GitHub access token + granted scope back so the web
|
|
310
|
+
// callback can persist it (encrypted, short-TTL) for the repo picker/creator.
|
|
311
|
+
// auth.js stays kernel-clean — it never writes the token to the DB itself; the
|
|
312
|
+
// token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
|
|
313
|
+
// even returns it.
|
|
314
|
+
const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
|
|
315
|
+
return { token, builder, expiresAt, ...captured };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// -------------------------------------------------------------------------
|
|
319
|
+
// Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
|
|
320
|
+
// -------------------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
// Build the hub's authorize URL. The instance redirects the browser here (instead
|
|
323
|
+
// of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
|
|
324
|
+
// instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
|
|
325
|
+
// by provisioning); state is the same CSRF nonce the callback validates.
|
|
326
|
+
// PURE — construct the hub authorize URL (testable without branding/env). Trailing
|
|
327
|
+
// slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
|
|
328
|
+
|
|
329
|
+
module.exports = {
|
|
330
|
+
buildAuthorizeUrl,
|
|
331
|
+
completeWebFlow,
|
|
332
|
+
fetchGithubUser,
|
|
333
|
+
newSessionToken,
|
|
334
|
+
pollDeviceFlow,
|
|
335
|
+
startDeviceFlow,
|
|
336
|
+
};
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
// src/bongos/auth-idp.js — federated sign-in against the identity hub: the IdP
|
|
2
|
+
// authorize/exchange flow, hub assertion + back-channel logout verification with
|
|
3
|
+
// their replay guards, and the device exchange that rides an assertion.
|
|
4
|
+
//
|
|
5
|
+
// Carved out of src/bongos/auth.js by task 1003206 (goal 1000079, the R03 plan)
|
|
6
|
+
// to bring the kernel identity file under the 1,500-line ratchet. This is the
|
|
7
|
+
// highest-risk file in the carve, so the move is STRICTLY structural: bodies
|
|
8
|
+
// unchanged, auth.js requires every name back, and the middleware surface
|
|
9
|
+
// (requireBuilder / requireRank / requirePermission / the page gates) never left.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const crypto = require('node:crypto');
|
|
14
|
+
const {
|
|
15
|
+
upsertBuilder,
|
|
16
|
+
seatFirstAdminIfUnclaimed,
|
|
17
|
+
createSession,
|
|
18
|
+
lookupSession,
|
|
19
|
+
getBuilderById,
|
|
20
|
+
getBuilderByGithubId,
|
|
21
|
+
hasInvitedAccessRequest,
|
|
22
|
+
accessStateByLogin,
|
|
23
|
+
RANKS,
|
|
24
|
+
} = require('./db-kernel');
|
|
25
|
+
const { pool } = require('./pool');
|
|
26
|
+
const seams = require('../module-seams');
|
|
27
|
+
const ic = require('../instance-config');
|
|
28
|
+
const { branding, userAgent } = require('../branding');
|
|
29
|
+
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, reportActivityRollup, reportMembershipCheckIn, safeSyncRankRole } = require('./auth-admission.js');
|
|
30
|
+
const { loadIdpConfig } = require('./auth-config.js');
|
|
31
|
+
const { newSessionToken } = require('./auth-github.js');
|
|
32
|
+
|
|
33
|
+
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
34
|
+
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
|
|
35
|
+
u.searchParams.set('client_id', clientId);
|
|
36
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
37
|
+
u.searchParams.set('state', state);
|
|
38
|
+
return u.toString();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildIdpAuthorizeUrl(state, redirectUri) {
|
|
42
|
+
const idp = loadIdpConfig();
|
|
43
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
44
|
+
return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Server-to-server: redeem the hub authorization code for identity. Authenticates
|
|
48
|
+
// with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
|
|
49
|
+
// (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
|
|
50
|
+
async function exchangeIdpCode({ code, redirectUri, idp }) {
|
|
51
|
+
const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
|
|
52
|
+
method: 'POST',
|
|
53
|
+
headers: {
|
|
54
|
+
'Content-Type': 'application/json',
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
'User-Agent': userAgent(),
|
|
57
|
+
},
|
|
58
|
+
body: JSON.stringify({
|
|
59
|
+
client_id: idp.clientId,
|
|
60
|
+
client_secret: idp.clientSecret,
|
|
61
|
+
code,
|
|
62
|
+
redirect_uri: redirectUri,
|
|
63
|
+
}),
|
|
64
|
+
});
|
|
65
|
+
if (!res.ok) {
|
|
66
|
+
const text = await res.text().catch(() => '');
|
|
67
|
+
throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
|
|
68
|
+
}
|
|
69
|
+
const data = await res.json();
|
|
70
|
+
if (!data || data.github_id == null) {
|
|
71
|
+
throw new Error('hub /sso/token returned no identity');
|
|
72
|
+
}
|
|
73
|
+
return data;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Complete the instance web sign-in via the hub. Sources identity from the hub's
|
|
77
|
+
// /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
|
|
78
|
+
// maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
|
|
79
|
+
// identity SOURCE differs (the hub, not github.com). The shared PURE gate
|
|
80
|
+
// (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
|
|
81
|
+
// path (SR-19). Returns the same { token, builder, expiresAt } shape.
|
|
82
|
+
async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
|
|
83
|
+
const idp = loadIdpConfig();
|
|
84
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
85
|
+
const identity = await exchangeIdpCode({ code, redirectUri, idp });
|
|
86
|
+
const ghId = identity.github_id;
|
|
87
|
+
const ghLogin = identity.github_login;
|
|
88
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
89
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
90
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
91
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
92
|
+
const err = new Error('not_approved');
|
|
93
|
+
err.code = 'NOT_APPROVED';
|
|
94
|
+
throw err;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
let builder = await upsertBuilder({
|
|
98
|
+
githubId: ghId,
|
|
99
|
+
githubLogin: ghLogin,
|
|
100
|
+
displayName: identity.display_name || ghLogin,
|
|
101
|
+
avatarUrl: identity.avatar_url,
|
|
102
|
+
});
|
|
103
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
104
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
105
|
+
const token = newSessionToken();
|
|
106
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
107
|
+
await createSession({
|
|
108
|
+
builderId: builder.id,
|
|
109
|
+
token,
|
|
110
|
+
source: 'web',
|
|
111
|
+
userAgent: ua ?? null,
|
|
112
|
+
expiresAt,
|
|
113
|
+
});
|
|
114
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
115
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
116
|
+
// federated web sign-in. The rollup is CHAINED after the check-in (ADR 0171 D7):
|
|
117
|
+
// the hub now requires a membership row before accepting activity, so a first
|
|
118
|
+
// sign-in's rollup must not race its own check-in. Both swallow all errors, so
|
|
119
|
+
// the chain never rejects and the sign-in never waits on it.
|
|
120
|
+
// Both helpers are async (always return a promise) and swallow their own
|
|
121
|
+
// errors; the .catch is belt-and-braces so this floating chain can never
|
|
122
|
+
// surface an unhandledRejection even if a future edit lets one throw.
|
|
123
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url })
|
|
124
|
+
.then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
|
|
125
|
+
.catch(() => {});
|
|
126
|
+
return { token, builder, expiresAt };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// -------------------------------------------------------------------------
|
|
130
|
+
// Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
|
|
131
|
+
// assertion OFFLINE and mints its own cli session. The CLI runs the device flow
|
|
132
|
+
// against the HUB (GitHub device codes can't cross apps), the hub signs an
|
|
133
|
+
// audience-scoped Ed25519 assertion, and the instance verifies it here with the
|
|
134
|
+
// hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
|
|
135
|
+
// -------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
|
|
138
|
+
|
|
139
|
+
// The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
|
|
140
|
+
// instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
|
|
141
|
+
function loadHubPublicKey() {
|
|
142
|
+
const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
|
|
143
|
+
if (!b64) return null;
|
|
144
|
+
try {
|
|
145
|
+
return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
|
|
146
|
+
} catch (err) {
|
|
147
|
+
console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Verify a hub-signed assertion (the compact-JWT format minted by the hub's
|
|
153
|
+
// platform-identity/hub-keys.js — kept in lockstep across the core<->module
|
|
154
|
+
// boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
|
|
155
|
+
// public key), then the issuer, audience (must equal THIS instance's client_id),
|
|
156
|
+
// and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
|
|
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() } = {}) {
|
|
160
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
161
|
+
const parts = String(token || '').split('.');
|
|
162
|
+
if (parts.length !== 3) throw new Error('malformed assertion');
|
|
163
|
+
const [h, p, s] = parts;
|
|
164
|
+
let header;
|
|
165
|
+
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
166
|
+
catch { throw new Error('malformed assertion header'); }
|
|
167
|
+
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
|
|
168
|
+
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
169
|
+
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
170
|
+
if (!ok) throw new Error('assertion signature invalid');
|
|
171
|
+
let payload;
|
|
172
|
+
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
173
|
+
catch { throw new Error('malformed assertion payload'); }
|
|
174
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
|
|
175
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
|
|
176
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
|
|
177
|
+
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
178
|
+
return payload;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The purpose marker on a hub back-channel logout token — kept in lockstep with
|
|
182
|
+
// the hub minter (platform-identity/hub-keys.js LOGOUT_PURPOSE) across the
|
|
183
|
+
// boundary (ADR 0091 §2). Task 1002238.
|
|
184
|
+
const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
|
|
185
|
+
|
|
186
|
+
// Verify a hub-signed BACK-CHANNEL LOGOUT token (ADR 0144, task 1002238) — the
|
|
187
|
+
// logout sibling of verifyHubAssertion, same compact-EdDSA-JWT envelope, kept in
|
|
188
|
+
// lockstep with the hub minter (hub-keys.js mintHubLogoutToken). Verifies the
|
|
189
|
+
// Ed25519 signature FIRST (alg HARDCODED to Ed25519 regardless of the header, so
|
|
190
|
+
// no algorithm-confusion), then issuer, audience (must equal THIS instance's
|
|
191
|
+
// client_id, so a token minted for another project can't be replayed here),
|
|
192
|
+
// expiry, and that purpose === 'backchannel-logout'. Requiring `purpose` is what
|
|
193
|
+
// makes login assertions and logout tokens mutually non-interchangeable: a login
|
|
194
|
+
// assertion has no purpose (rejected here), and a logout token has no
|
|
195
|
+
// github_login (rejected by verifyHubAssertion). Returns the validated payload
|
|
196
|
+
// ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
|
|
197
|
+
function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
198
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
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');
|
|
215
|
+
if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
|
|
216
|
+
if (payload.sub == null) throw new Error('logout token missing subject');
|
|
217
|
+
return payload;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
221
|
+
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
222
|
+
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
223
|
+
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
224
|
+
// short window and that the aud binding is the primary control.
|
|
225
|
+
const _seenAssertionJti = new Map(); // jti -> expiry ms
|
|
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
|
+
}
|
|
233
|
+
|
|
234
|
+
// Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
|
|
235
|
+
// assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
|
|
236
|
+
// a replay within the ~120s TTL is a no-op. Without this, a captured token stays a
|
|
237
|
+
// repeatable "log this builder out" primitive for its whole life — it would keep
|
|
238
|
+
// killing sessions the builder legitimately creates AFTER the token was minted
|
|
239
|
+
// (the one non-idempotent replay effect; revoking already-dead sessions is
|
|
240
|
+
// otherwise harmless). Separate map from assertions so the two token types never
|
|
241
|
+
// share dedupe state. In-memory per process; a restart clears it (acceptable — the
|
|
242
|
+
// window is short and the aud binding is the primary control). Exported for the
|
|
243
|
+
// route (routes/auth.js) since the DB revoke lives there, not in the kernel.
|
|
244
|
+
const _seenLogoutJti = new Map(); // jti -> expiry ms
|
|
245
|
+
function logoutTokenJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
246
|
+
for (const [k, exp] of _seenLogoutJti) if (exp < nowMs) _seenLogoutJti.delete(k);
|
|
247
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
248
|
+
if (_seenLogoutJti.has(jti)) return true;
|
|
249
|
+
_seenLogoutJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
254
|
+
// admission gate + upsertBuilder + createSession(source='cli') as the direct device
|
|
255
|
+
// flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
|
|
256
|
+
async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
257
|
+
const idp = loadIdpConfig();
|
|
258
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
259
|
+
const publicKey = loadHubPublicKey();
|
|
260
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
261
|
+
const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
|
|
262
|
+
// Reject a replayed assertion (same jti) within its window.
|
|
263
|
+
if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
|
|
264
|
+
throw new Error('assertion already used');
|
|
265
|
+
}
|
|
266
|
+
const ghId = payload.sub;
|
|
267
|
+
const ghLogin = payload.github_login;
|
|
268
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
269
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
270
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
271
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
272
|
+
const err = new Error('not_approved');
|
|
273
|
+
err.code = 'NOT_APPROVED';
|
|
274
|
+
throw err;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
let builder = await upsertBuilder({
|
|
278
|
+
githubId: ghId,
|
|
279
|
+
githubLogin: ghLogin,
|
|
280
|
+
displayName: payload.display_name || ghLogin,
|
|
281
|
+
avatarUrl: payload.avatar_url,
|
|
282
|
+
});
|
|
283
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
284
|
+
await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
|
|
285
|
+
const token = newSessionToken();
|
|
286
|
+
await createSession({
|
|
287
|
+
builderId: builder.id,
|
|
288
|
+
token,
|
|
289
|
+
source: 'cli',
|
|
290
|
+
userAgent: ua ?? null,
|
|
291
|
+
expiresAt: null,
|
|
292
|
+
});
|
|
293
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
294
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
|
|
295
|
+
// federated CLI sign-in. Rollup chained after check-in (ADR 0171 D7): the hub's
|
|
296
|
+
// membership requirement must not race a first sign-in; both swallow errors.
|
|
297
|
+
// Async + self-swallowing helpers; .catch is belt-and-braces (see web flow).
|
|
298
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url })
|
|
299
|
+
.then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
|
|
300
|
+
.catch(() => {});
|
|
301
|
+
return { token, builder };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// -------------------------------------------------------------------------
|
|
305
|
+
// Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
|
|
306
|
+
// -------------------------------------------------------------------------
|
|
307
|
+
// Links a Discord account to an ALREADY-AUTHENTICATED builder. Client creds
|
|
308
|
+
// come from env (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET) or, for local dev,
|
|
309
|
+
// ~/.config/otb/discord-bot.json — same env-first-then-file shape as GitHub.
|
|
310
|
+
// scope=identify (the Discord user id, for the link) + guilds.join (so the
|
|
311
|
+
// server can drop the just-linked builder straight into the guild — the
|
|
312
|
+
// "link-first auto-join" onboarding gate, F7/ADR 0033: a builder approves the
|
|
313
|
+
// link BEFORE entering, so their rank role is already correct on arrival; there
|
|
314
|
+
// is no invite link to share or leak). guilds.join is only USABLE alongside the
|
|
315
|
+
// bot adding the member, and only if the bot holds CREATE_INSTANT_INVITE — see
|
|
316
|
+
// addDiscordGuildMember + docs/discord/role-sync.md. The pure helpers
|
|
317
|
+
// (discordAuthorizeUrl, isValidDiscordUserId) are exported for unit testing
|
|
318
|
+
// without creds or network.
|
|
319
|
+
|
|
320
|
+
module.exports = {
|
|
321
|
+
buildIdpAuthorizeUrl,
|
|
322
|
+
completeDeviceExchange,
|
|
323
|
+
completeIdpFlow,
|
|
324
|
+
exchangeIdpCode,
|
|
325
|
+
idpAuthorizeUrl,
|
|
326
|
+
loadHubPublicKey,
|
|
327
|
+
logoutTokenJtiSeenAndRecord,
|
|
328
|
+
verifyHubAssertion,
|
|
329
|
+
verifyHubLogoutToken,
|
|
330
|
+
};
|