@bongos/core 1.19.331 → 1.19.333

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/.bongos-core.json +163 -43
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/assets/cosmos.css +4 -0
  26. package/public-landing/projects.html +103 -23
  27. package/scripts/gds/box-infra.js +280 -0
  28. package/scripts/gds/box.js +1 -245
  29. package/scripts/gds/fitness-checks-identity.js +320 -0
  30. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  31. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  32. package/scripts/gds/fitness-lib.js +82 -0
  33. package/scripts/gds/fitness.js +55 -668
  34. package/scripts/gds/provision-config.js +204 -0
  35. package/scripts/gds/provision-net.js +221 -0
  36. package/scripts/gds/provision-repo.js +849 -0
  37. package/scripts/gds/provision-units.js +262 -0
  38. package/scripts/gds/provision.js +4 -1395
  39. package/src/bongos/auth-admission.js +264 -0
  40. package/src/bongos/auth-config.js +151 -0
  41. package/src/bongos/auth-discord.js +198 -0
  42. package/src/bongos/auth-github.js +336 -0
  43. package/src/bongos/auth-idp.js +330 -0
  44. package/src/bongos/auth.js +5 -1100
  45. package/src/module-api.js +5 -1
  46. package/tests/auth_admission_gate.mjs +3 -1
  47. package/tests/est_advisory.mjs +3 -1
  48. package/tests/goal_advisory.mjs +3 -2
  49. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  50. package/tests/goal_subtitle.mjs +5 -2
  51. package/tests/goals_card_view.mjs +10 -3
  52. package/tests/grader_generated_provenance.mjs +3 -1
  53. package/tests/hall_palette.mjs +3 -1
  54. package/tests/helpers.mjs +58 -25
  55. package/tests/lib_sh_resolution.mjs +3 -1
  56. package/tests/membership_kind_lockstep.mjs +3 -1
  57. package/tests/migration_allocation.mjs +5 -2
  58. package/tests/patch_gate_ordering.mjs +3 -1
  59. package/tests/projects_hub_pre_uat.mjs +53 -0
  60. package/tests/rank_tier_single_source.mjs +16 -3
  61. package/tests/reward_gate.mjs +3 -2
@@ -54,1107 +54,12 @@ const seams = require('../module-seams');
54
54
  const ic = require('../instance-config');
55
55
  const { branding, userAgent } = require('../branding');
56
56
 
57
- const SCOPE = 'read:user';
57
+ const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
58
+ const { firstAdminLogin, firstTimeAdmissionBlocked, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, webAdmissionStatus } = require('./auth-admission.js');
59
+ const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
60
+ const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
61
+ const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
58
62
 
59
- // The repo-acting scope the onboarding wizard opts into. `repo`, NOT `public_repo`.
60
- //
61
- // ADR 0143 §1 originally chose `public_repo` precisely to avoid the all-or-nothing
62
- // `repo`, and named its own reversal path: "revisit to `repo` only if private
63
- // support is needed". It is needed. `public_repo` grants write to PUBLIC repos
64
- // only, so ADOPTING an existing PRIVATE repo dies at the provisioner's scaffold
65
- // push with GitHub 403 "Write access to repository not granted" — observed live on
66
- // a private repo of the owner's. ADR 0155 takes the reversal at the owner's
67
- // explicit call, accepting the cost it names: a token we persist can now write to
68
- // EVERY repo the user owns, not just their public ones.
69
- const REPO_SCOPE = 'repo';
70
-
71
- // Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
72
- // and the CLI Device Flow ever need. The onboarding wizard
73
- // (public-landing/create.html), however, lists + creates + is pushed to by the
74
- // provisioner on the signed-in user's own GitHub repos (task 1002099) — which
75
- // `read:user` cannot do — so it OPTS IN to REPO_SCOPE. Only the wizard passes
76
- // { repos:true }; every other sign-in stays identity-only. Pure + exported so the
77
- // exact scope string (a security-critical value) is unit-testable.
78
- function scopeForWebFlow({ repos = false } = {}) {
79
- return repos ? `${SCOPE} ${REPO_SCOPE}` : SCOPE;
80
- }
81
-
82
- // Does a scope string GitHub ACTUALLY GRANTED still satisfy the repo surfaces?
83
- //
84
- // WHY THIS EXISTS. Widening scopeForWebFlow does NOT upgrade tokens already in
85
- // `github_oauth_tokens` — a row minted before ADR 0155 carries `public_repo`, which
86
- // is enough to list and create PUBLIC repos. So every wizard call would still 200
87
- // while silently omitting the user's PRIVATE repos from the picker, and the failure
88
- // would resurface far downstream at the provisioner's push. Callers check this and
89
- // re-prompt (repo_scope_required) instead of degrading quietly.
90
- //
91
- // GitHub returns granted scopes comma-separated in its own order (e.g.
92
- // "public_repo,read:user"), so parse rather than substring-match — `public_repo`
93
- // CONTAINS "repo" and a naive match would accept exactly the stale token this
94
- // guards against. `repo` implies `public_repo`, so `repo` alone is sufficient.
95
- function grantedScopeCoversRepos(granted) {
96
- return String(granted || '')
97
- .split(/[,\s]+/)
98
- .map((s) => s.trim())
99
- .includes(REPO_SCOPE);
100
- }
101
-
102
- // In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
103
- // Device flow is ephemeral; restart of the Node process invalidates pending flows
104
- // (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
105
- // moving this to Redis or a tiny `device_flows` table.
106
- const deviceFlows = new Map();
107
-
108
- function loadGithubCreds() {
109
- // Resolve the configured instance prefix first (CLOUDBONGOS_ on a fresh
110
- // instance, OTB_ on this one), then the legacy GDS_/PMS_ names — so the
111
- // droplet's EnvironmentFile rename can lag the code deploy without breaking
112
- // auth (R61 / task 1200).
113
- const clientId = ic.resolveEnv('GITHUB_CLIENT_ID');
114
- const clientSecret = ic.resolveEnv('GITHUB_CLIENT_SECRET');
115
- if (clientId && clientSecret) {
116
- return { client_id: clientId, client_secret: clientSecret };
117
- }
118
- // Local-dev fallback: try the new filename first, then the legacy one — each
119
- // across the configured config dir and the legacy ~/.config/otb dir.
120
- for (const name of ['gds-github.json', 'pms-github.json']) {
121
- try {
122
- const raw = ic.readConfigFileSync(name);
123
- if (raw) {
124
- const creds = JSON.parse(raw);
125
- if (creds.client_id && creds.client_secret) return creds;
126
- }
127
- } catch (_) {
128
- /* try next */
129
- }
130
- }
131
- return null;
132
- }
133
-
134
- function authConfigured() {
135
- return !!loadGithubCreds();
136
- }
137
-
138
- // -------------------------------------------------------------------------
139
- // Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
140
- // CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
141
- // directly. Identity is federated; authority is NOT — the instance still runs its
142
- // OWN admission gate + upsertBuilder + createSession, so the hub only answers
143
- // "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
144
- // unchanged fallback for self-hosted instances.
145
- // -------------------------------------------------------------------------
146
-
147
- // The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
148
- // false so a branding hiccup degrades to the direct-GitHub path rather than
149
- // breaking sign-in.
150
- function idpEnabled() {
151
- try { return branding().auth?.idp === true; } catch { return false; }
152
- }
153
-
154
- // Resolve the hub federation config. The switch is the flag above; the hub origin
155
- // + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
156
- // _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
157
- // branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
158
- // (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
159
- // flag is on AND all three resolve → callers fall back to direct GitHub.
160
- function loadIdpConfig() {
161
- if (!idpEnabled()) return null;
162
- const origin = ic.resolveEnv('HUB_ORIGIN');
163
- const clientId = ic.resolveEnv('HUB_CLIENT_ID');
164
- const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
165
- if (origin && clientId && clientSecret) {
166
- return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
167
- }
168
- return null;
169
- }
170
-
171
- function idpConfigured() {
172
- return !!loadIdpConfig();
173
- }
174
-
175
- // The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
176
- // builder is the instance's configured firstAdmin, else a plain member. NEVER a rank.
177
- //
178
- // 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
179
- // `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
180
- // project membership says what it means. The legacy spelling still goes on the wire for
181
- // the cross-version window; see reportMembershipCheckIn.
182
- function membershipKind({ githubLogin } = {}) {
183
- const fa = firstAdminLogin();
184
- if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
185
- return 'member';
186
- }
187
-
188
- // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
189
- // github_id builds on project <client_id>, coarse membership <membership_kind>".
190
- //
191
- // BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
192
- // HUB, a different instance on its own upgrade schedule: send only the new field and
193
- // an un-upgraded hub silently defaults every membership to non-owner (it would drop
194
- // the owner flag); send only the legacy field and an upgraded hub keeps translating
195
- // forever. Sending both is what makes the two ends independent — the removal task
196
- // drops `role` once the hub is past the window. Runs on every
197
- // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
198
- // project still appears in the builder's My Projects with no hub browser session.
199
- // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
200
- // the onboarding stage tick). No-op on a self-hosted instance (idp unset).
201
- async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
202
- const idp = loadIdpConfig();
203
- if (!idp) return;
204
- const kind = membershipKind({ githubLogin });
205
- try {
206
- await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
207
- method: 'POST',
208
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
209
- body: JSON.stringify({
210
- client_id: idp.clientId,
211
- client_secret: idp.clientSecret,
212
- github_id: githubId,
213
- github_login: githubLogin,
214
- display_name: displayName ?? null,
215
- avatar_url: avatarUrl ?? null,
216
- membership_kind: kind,
217
- // LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
218
- role: kind === 'member' ? 'builder' : kind,
219
- }),
220
- });
221
- } catch (err) {
222
- console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
223
- }
224
- }
225
-
226
- // Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
227
- // builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
228
- // for the internal scouting directory. Fires on every federated sign-in ("on key
229
- // events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
230
- // not federated — never fails a sign-in.
231
- async function reportActivityRollup({ builderId, githubId }) {
232
- const idp = loadIdpConfig();
233
- if (!idp) return;
234
- try {
235
- // Snapshot the builder's talent signals directly from the DB (auth.js is a
236
- // kernel file — it uses pool, never a non-kernel domain module like metrics.js).
237
- const b = await pool.query(
238
- 'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
239
- [builderId],
240
- );
241
- if (!b.rows[0]) return;
242
- const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
243
- const row = b.rows[0];
244
- await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
245
- method: 'POST',
246
- headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
247
- body: JSON.stringify({
248
- client_id: idp.clientId,
249
- client_secret: idp.clientSecret,
250
- github_id: githubId,
251
- credits: Number(row.total_credits) || 0,
252
- tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
253
- karma: Number(row.karma) || 0,
254
- rank: row.rank || null,
255
- disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
256
- }),
257
- });
258
- } catch (err) {
259
- console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
260
- }
261
- }
262
-
263
- // Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
264
- // sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
265
- // "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
266
- // Archon marked their access_request 'invited'. An EXISTING builder (a row
267
- // already exists for their github_id) ALWAYS passes — the gate never locks out
268
- // someone already enrolled.
269
- //
270
- // OTB_OPEN_XENOS_ENROLLMENT is the escape-hatch: set it truthy ('1' or 'true')
271
- // to restore fully-open self-enrollment (anyone with a GitHub account becomes a
272
- // Xenos) WITHOUT a code change, for an owner who deliberately wants open
273
- // enrollment. Default (unset/anything-else) = invite required. See ADR 0050.
274
- function openEnrollmentEnabled() {
275
- const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
276
- return v === '1' || v === 'true';
277
- }
278
-
279
- // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
280
- // Returns true when a first-time account must be blocked. `existing` is the
281
- // builder row (or null) from getBuilderByGithubId; `approved` is the result of
282
- // hasInvitedAccessRequest. Blocks only when: not already a builder AND open
283
- // enrollment is off AND not invite-approved. Exported for direct unit testing.
284
- function firstTimeAdmissionBlocked({ existing, approved, login }) {
285
- if (existing || openEnrollmentEnabled() || approved) return false;
286
- // The configured firstAdmin is always admitted on a fresh instance so the
287
- // bootstrap (maybeSeatFirstAdmin) can seat them as the first Archon — the owner
288
- // must not be gated out of their own new instance when no Archon exists yet to
289
- // approve them (the chicken-and-egg R62 / ADR 0062 §6 left open).
290
- // seatFirstAdminIfUnclaimed is race-safe and only fires while NO Archon exists,
291
- // so this never escalates an account on an established instance; it only lets
292
- // the configured owner login through admission.
293
- const fa = firstAdminLogin();
294
- if (fa && String(login || '').toLowerCase() === fa.toLowerCase()) return false;
295
- return true;
296
- }
297
-
298
- // webAdmissionStatus — "would this login get in RIGHT NOW, and is it worth
299
- // waiting?" (task 1002097). Backs the waiting page that replaced the red
300
- // "not approved" dead-end a firstAdmin hits during the bootstrap window.
301
- //
302
- // `admitted` deliberately mirrors firstTimeAdmissionBlocked's own terms —
303
- // member OR open-enrollment OR invited OR configured-firstAdmin — so the probe
304
- // can never say "come in" while the real gate says otherwise. It reads state;
305
- // it decides nothing. firstTimeAdmissionBlocked remains the one gate both flows
306
- // call (SR-19); if that logic changes, this must change with it.
307
- // tests/web_admission_status.mjs sweeps the input space asserting the two agree.
308
- //
309
- // `expectedSoon` is the broader "keep polling" signal: admitted, or waiting on
310
- // an Archon (pending), or the configured firstAdmin whose instance is still
311
- // booting. A visitor who is none of these is a genuine stranger and gets the
312
- // existing request-access page rather than a spinner that never resolves.
313
- //
314
- // NB the onboarding module's public GET /access-requests/status (used by the
315
- // CLI) reports member|invited|pending|none but knows nothing about the
316
- // firstAdmin bypass or open enrollment — so it reports the OWNER as 'none'
317
- // forever. That is exactly why this auth-domain probe exists.
318
- async function webAdmissionStatus(login) {
319
- const name = String(login || '').trim();
320
- if (!name) return { admitted: false, expectedSoon: false };
321
-
322
- const fa = firstAdminLogin();
323
- const isFirstAdmin = !!fa && name.toLowerCase() === fa.toLowerCase();
324
-
325
- let state = { member: false, invited: false, pending: false };
326
- try {
327
- state = await accessStateByLogin(name);
328
- } catch (err) {
329
- // A DB hiccup must not turn the waiting page into a false "you're in".
330
- // Fail closed on `admitted`; still let a firstAdmin keep polling, since
331
- // that is the bootstrap window this page exists for.
332
- console.error('[auth] webAdmissionStatus', err);
333
- return { admitted: false, expectedSoon: isFirstAdmin };
334
- }
335
-
336
- const admitted = state.member || openEnrollmentEnabled() || state.invited || isFirstAdmin;
337
- return { admitted, expectedSoon: admitted || state.pending || isFirstAdmin };
338
- }
339
-
340
- // First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
341
- // login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
342
- // else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
343
- // auto-seated"; the owner sets it when scaffolding the instance. Trimmed; '' → null.
344
- function firstAdminLogin() {
345
- let login = ic.resolveEnv('FIRST_ADMIN');
346
- if (login === undefined) {
347
- try { login = branding().firstAdmin; } catch { login = undefined; }
348
- }
349
- login = (login || '').trim();
350
- return login || null;
351
- }
352
-
353
- // Seat the configured firstAdmin as Archon at their first enrollment, but only
354
- // while NO Archon exists yet (the db guard is atomic + race-safe, so this can
355
- // never escalate an account once an owner exists). Best-effort: a bootstrap
356
- // hiccup must never fail the sign-in. Returns the builder, with rank updated to
357
- // 'archon' on the returned object if this call seated it.
358
- async function maybeSeatFirstAdmin(builder) {
359
- const configured = firstAdminLogin();
360
- if (!configured || !builder || builder.rank === 'archon') return builder;
361
- if (String(builder.github_login || '').toLowerCase() !== configured.toLowerCase()) return builder;
362
- try {
363
- const seated = await seatFirstAdminIfUnclaimed(builder.id, builder.github_login);
364
- if (seated) {
365
- console.log(`[auth] first-admin bootstrap: seated ${builder.github_login} as Archon (configured firstAdmin, no prior Archon).`);
366
- return { ...builder, rank: 'archon' };
367
- }
368
- } catch (err) {
369
- console.error('[auth] first-admin bootstrap failed (non-fatal):', err.message);
370
- }
371
- return builder;
372
- }
373
-
374
- // safeSyncRankRole — give a builder the seeded rank-role matching their rank at
375
- // REGISTRATION (BV1.R95b, ADR 0151). governance_002's builder→role backfill is
376
- // run-once, so before this every builder admitted after that migration had no role
377
- // row at all and resolved to the EMPTY permission set — denied on every gated route.
378
- //
379
- // Called at each sign-in rather than only on first insert, and deliberately so: the
380
- // call is idempotent (two SELECTs and no write once the role already matches), it
381
- // also covers the rank write maybeSeatFirstAdmin just made (the firstAdmin bootstrap
382
- // flips xenos→archon outside any transaction), and it quietly repairs a builder whose
383
- // assignment drifted for any other reason.
384
- //
385
- // Resolved through the `government.rankSync` kernel port — auth.js is a KERNEL_FILE
386
- // and may not require a module (ADR 0083 / ADR 0091 §1). Runs on the pool: there is
387
- // no surrounding transaction here, unlike the two rank-change paths, which pass their
388
- // own client so the rotation is atomic with the rank write.
389
- //
390
- // BEST-EFFORT: a governance hiccup must never fail a sign-in, and the failure mode is
391
- // fail-closed anyway — no role means zero permissions, never extra ones.
392
- async function safeSyncRankRole(builder) {
393
- if (!builder || builder.id == null) return;
394
- try {
395
- await seams.resolveOptional('government.rankSync')
396
- ?.syncRank(null, { builderId: builder.id, rank: builder.rank });
397
- } catch (err) {
398
- console.error('[auth] rank-role sync failed (non-fatal):', err.message);
399
- }
400
- }
401
-
402
- function newSessionToken() {
403
- return crypto.randomBytes(32).toString('hex');
404
- }
405
-
406
- // -------------------------------------------------------------------------
407
- // GitHub helpers
408
- // -------------------------------------------------------------------------
409
-
410
- async function fetchGithubUser(accessToken) {
411
- const res = await fetch('https://api.github.com/user', {
412
- headers: {
413
- Authorization: `Bearer ${accessToken}`,
414
- 'User-Agent': userAgent(),
415
- Accept: 'application/vnd.github+json',
416
- },
417
- });
418
- if (!res.ok) {
419
- const text = await res.text();
420
- throw new Error(`GitHub /user failed (${res.status}): ${text}`);
421
- }
422
- return res.json();
423
- }
424
-
425
- async function exchangeCodeForToken(code, redirectUri) {
426
- const creds = loadGithubCreds();
427
- if (!creds) throw new Error('GitHub OAuth credentials not configured');
428
- const res = await fetch('https://github.com/login/oauth/access_token', {
429
- method: 'POST',
430
- headers: {
431
- 'Content-Type': 'application/x-www-form-urlencoded',
432
- Accept: 'application/json',
433
- },
434
- body: new URLSearchParams({
435
- client_id: creds.client_id,
436
- client_secret: creds.client_secret,
437
- code,
438
- redirect_uri: redirectUri,
439
- }).toString(),
440
- });
441
- const data = await res.json();
442
- if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
443
- // Return the GRANTED scope alongside the token. GitHub echoes back the scopes
444
- // the user actually consented to (space/comma-separated) — the caller checks it
445
- // before persisting a repo-capable token, so a user who declined the broader
446
- // consent never has one stored (task 1002099 / ADR 0143).
447
- return { accessToken: data.access_token, scope: data.scope || '' };
448
- }
449
-
450
- // -------------------------------------------------------------------------
451
- // Device Flow (CLI)
452
- // -------------------------------------------------------------------------
453
-
454
- async function startDeviceFlow() {
455
- const creds = loadGithubCreds();
456
- if (!creds) throw new Error('GitHub OAuth credentials not configured');
457
- const res = await fetch('https://github.com/login/device/code', {
458
- method: 'POST',
459
- headers: {
460
- 'Content-Type': 'application/x-www-form-urlencoded',
461
- Accept: 'application/json',
462
- },
463
- body: new URLSearchParams({ client_id: creds.client_id, scope: SCOPE }).toString(),
464
- });
465
- if (!res.ok) {
466
- const text = await res.text();
467
- throw new Error(`device/code failed (${res.status}): ${text}`);
468
- }
469
- const data = await res.json();
470
- // GitHub returns HTTP 200 with an { error, error_description } body for OAuth-App
471
- // misconfig — most often device_flow_disabled (Device Flow not enabled on the App)
472
- // or incorrect_client_credentials. res.ok is TRUE there, so without this the missing
473
- // device_code silently produced a broken flow / an opaque 500 (the 2026-07-08 demo
474
- // login failure). Mirror the token-exchange guard (data.error, above); task 2126.
475
- if (data.error) {
476
- // device_flow_disabled is self-inflicted + common: GitHub defaults Device Flow OFF on new
477
- // Apps and the one-click manifest can't enable it (task 2129). Make the CLI error self-
478
- // service by naming the right settings surface. We hold only client_id here (not the app
479
- // slug — that lives on the control plane), so we can't build /settings/apps/<slug>; but the
480
- // id PREFIX distinguishes a GitHub App (Iv*) from an OAuth App (Ov*), which picks the right
481
- // listing to open.
482
- let hint = '';
483
- if (data.error === 'device_flow_disabled') {
484
- const where = /^Iv/i.test(creds.client_id || '')
485
- ? 'https://github.com/settings/apps (org-owned apps: https://github.com/organizations/<org>/settings/apps)'
486
- : 'https://github.com/settings/developers';
487
- 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.`;
488
- }
489
- throw new Error(`GitHub rejected device-flow start: ${data.error}${data.error_description ? ` — ${data.error_description}` : ''}${hint}`);
490
- }
491
- if (!data.device_code) {
492
- throw new Error('GitHub device/code returned no device_code (unexpected response shape).');
493
- }
494
- // data: { device_code, user_code, verification_uri, expires_in, interval }
495
- deviceFlows.set(data.device_code, {
496
- interval: data.interval || 5,
497
- expires_at: Date.now() + data.expires_in * 1000,
498
- last_polled_at: 0,
499
- });
500
- // Reap expired flows (cheap)
501
- for (const [k, v] of deviceFlows) {
502
- if (v.expires_at < Date.now()) deviceFlows.delete(k);
503
- }
504
- return {
505
- device_code: data.device_code,
506
- user_code: data.user_code,
507
- verification_uri: data.verification_uri,
508
- expires_in: data.expires_in,
509
- interval: data.interval || 5,
510
- };
511
- }
512
-
513
- async function pollDeviceFlow(deviceCode, userAgent) {
514
- const flow = deviceFlows.get(deviceCode);
515
- if (!flow) return { state: 'expired' };
516
- if (flow.expires_at < Date.now()) {
517
- deviceFlows.delete(deviceCode);
518
- return { state: 'expired' };
519
- }
520
- // Server-side throttling: GitHub returns slow_down if we hammer; we
521
- // also enforce the interval client-to-server.
522
- const now = Date.now();
523
- if (now - flow.last_polled_at < flow.interval * 1000) {
524
- return { state: 'slow_down', interval: flow.interval };
525
- }
526
- flow.last_polled_at = now;
527
-
528
- const creds = loadGithubCreds();
529
- const res = await fetch('https://github.com/login/oauth/access_token', {
530
- method: 'POST',
531
- headers: {
532
- 'Content-Type': 'application/x-www-form-urlencoded',
533
- Accept: 'application/json',
534
- },
535
- body: new URLSearchParams({
536
- client_id: creds.client_id,
537
- device_code: deviceCode,
538
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
539
- }).toString(),
540
- });
541
- const data = await res.json();
542
- if (data.error === 'authorization_pending') return { state: 'pending' };
543
- if (data.error === 'slow_down') {
544
- flow.interval = (flow.interval || 5) + 5;
545
- return { state: 'slow_down', interval: flow.interval };
546
- }
547
- if (data.error === 'expired_token') {
548
- deviceFlows.delete(deviceCode);
549
- return { state: 'expired' };
550
- }
551
- if (data.error === 'access_denied') {
552
- deviceFlows.delete(deviceCode);
553
- return { state: 'denied' };
554
- }
555
- if (data.access_token) {
556
- deviceFlows.delete(deviceCode);
557
- const ghUser = await fetchGithubUser(data.access_token);
558
- // Admission gate (SR-19 / task #1000): mirror completeWebFlow EXACTLY. The
559
- // CLI device flow used to call upsertBuilder unconditionally, so any GitHub
560
- // account could self-enroll as a Xenos + mint a CLI session with no Archon
561
- // approval — contradicting the "Archon admission is the only gate" rule. Now
562
- // a FIRST-TIME account (no existing builder row for this github_id) is
563
- // admitted only if open enrollment is on OR an Archon marked their
564
- // access_request 'invited'. An EXISTING builder ALWAYS passes, so an approved
565
- // builder can never be locked out of CLI enrollment. See ADR 0050.
566
- const existing = await getBuilderByGithubId(ghUser.id);
567
- // Only consult the access_request table for a genuine first-timer (and only
568
- // when open enrollment is off) — an existing builder short-circuits to
569
- // ALWAYS-pass, matching completeWebFlow's `if (!existing)` lookup.
570
- if (!existing && !openEnrollmentEnabled()) {
571
- const approved = await hasInvitedAccessRequest(ghUser.login);
572
- if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
573
- const err = new Error('not_approved');
574
- err.code = 'NOT_APPROVED';
575
- throw err;
576
- }
577
- }
578
- let builder = await upsertBuilder({
579
- githubId: ghUser.id,
580
- githubLogin: ghUser.login,
581
- displayName: ghUser.name || ghUser.login,
582
- avatarUrl: ghUser.avatar_url,
583
- });
584
- builder = await maybeSeatFirstAdmin(builder);
585
- await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
586
- const token = newSessionToken();
587
- await createSession({
588
- builderId: builder.id,
589
- token,
590
- source: 'cli',
591
- userAgent: userAgent ?? null,
592
- // CLI tokens have no expiry (manually revocable). Web tokens expire.
593
- expiresAt: null,
594
- });
595
- // V3.R75 (#294): tick the onboarding state machine. Best-effort — auth
596
- // success must NOT fail because the onboarding plumbing tripped.
597
- await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
598
- return { state: 'ok', token, builder };
599
- }
600
- return { state: 'pending' };
601
- }
602
-
603
- // -------------------------------------------------------------------------
604
- // Web Flow (browser)
605
- // -------------------------------------------------------------------------
606
-
607
- function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
608
- const creds = loadGithubCreds();
609
- if (!creds) throw new Error('GitHub OAuth credentials not configured');
610
- const u = new URL('https://github.com/login/oauth/authorize');
611
- u.searchParams.set('client_id', creds.client_id);
612
- u.searchParams.set('redirect_uri', redirectUri);
613
- // `repos:true` (the onboarding wizard only) requests the broader repo scope; every
614
- // other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
615
- u.searchParams.set('scope', scopeForWebFlow({ repos }));
616
- u.searchParams.set('state', state);
617
- return u.toString();
618
- }
619
-
620
- async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
621
- const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
622
- const ghUser = await fetchGithubUser(accessToken);
623
- // Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
624
- // with a GitHub account. A first-time visitor must have been approved by an
625
- // Archon (their access_request marked 'invited') before a builder row is
626
- // created. This Archon approval is the ONLY gate — newcomers build on hosted
627
- // dev boxes, so there is no GitHub repo-access / collaborator check anywhere
628
- // (2026-06-07). Established builders (a row already exists for this github_id)
629
- // always pass — this only blocks NEW first-time entries.
630
- //
631
- // SR-19 / task #1000 (2026-06-13): the CLI device flow (pollDeviceFlow,
632
- // /builder-setup) is now gated the SAME way — it previously called
633
- // upsertBuilder unconditionally, letting any GitHub account self-enroll as a
634
- // Xenos with no Archon approval. Both paths share firstTimeAdmissionBlocked()
635
- // so they cannot drift. An owner who wants fully-open self-enrollment sets
636
- // OTB_OPEN_XENOS_ENROLLMENT truthy (no code change); default = invite required.
637
- // See ADR 0050.
638
- const existing = await getBuilderByGithubId(ghUser.id);
639
- if (!existing && !openEnrollmentEnabled()) {
640
- const approved = await hasInvitedAccessRequest(ghUser.login);
641
- // Pass `login` so the configured-firstAdmin bypass (firstTimeAdmissionBlocked
642
- // L124-125) fires on WEB sign-in too — without it the founding owner is gated
643
- // out of their own fresh instance at "The Gate is Closed", a chicken-and-egg
644
- // since no Archon exists yet to approve them. The device flow (pollDeviceFlow,
645
- // ~L305) already passes login; this keeps the two paths from drifting (SR-19).
646
- if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
647
- const err = new Error('not_approved');
648
- err.code = 'NOT_APPROVED';
649
- // Carry the login so the route can tell an imminent admission (the
650
- // configured firstAdmin mid-bootstrap, or someone with a pending request)
651
- // from a genuine stranger, and show the "confirming access" waiting page
652
- // instead of a red dead-end (task 1002097). Attaching it here — rather
653
- // than re-deriving it in the route — keeps the login the gate actually
654
- // judged. It is NOT a decision: the route re-checks via webAdmissionStatus.
655
- err.login = ghUser.login;
656
- throw err;
657
- }
658
- }
659
- let builder = await upsertBuilder({
660
- githubId: ghUser.id,
661
- githubLogin: ghUser.login,
662
- displayName: ghUser.name || ghUser.login,
663
- avatarUrl: ghUser.avatar_url,
664
- });
665
- builder = await maybeSeatFirstAdmin(builder);
666
- await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
667
- const token = newSessionToken();
668
- // Web sessions expire after 30 days; the user re-auths on the page
669
- // when the cookie expires.
670
- const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
671
- await createSession({
672
- builderId: builder.id,
673
- token,
674
- source: 'web',
675
- userAgent: userAgent ?? null,
676
- expiresAt,
677
- });
678
- // V3.R75 (#294): tick the onboarding state machine. Best-effort.
679
- await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
680
- // Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
681
- // repo access, hand the GitHub access token + granted scope back so the web
682
- // callback can persist it (encrypted, short-TTL) for the repo picker/creator.
683
- // auth.js stays kernel-clean — it never writes the token to the DB itself; the
684
- // token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
685
- // even returns it.
686
- const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
687
- return { token, builder, expiresAt, ...captured };
688
- }
689
-
690
- // -------------------------------------------------------------------------
691
- // Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
692
- // -------------------------------------------------------------------------
693
-
694
- // Build the hub's authorize URL. The instance redirects the browser here (instead
695
- // of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
696
- // instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
697
- // by provisioning); state is the same CSRF nonce the callback validates.
698
- // PURE — construct the hub authorize URL (testable without branding/env). Trailing
699
- // slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
700
- function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
701
- const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
702
- u.searchParams.set('client_id', clientId);
703
- u.searchParams.set('redirect_uri', redirectUri);
704
- u.searchParams.set('state', state);
705
- return u.toString();
706
- }
707
-
708
- function buildIdpAuthorizeUrl(state, redirectUri) {
709
- const idp = loadIdpConfig();
710
- if (!idp) throw new Error('platform IdP not configured');
711
- return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
712
- }
713
-
714
- // Server-to-server: redeem the hub authorization code for identity. Authenticates
715
- // with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
716
- // (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
717
- async function exchangeIdpCode({ code, redirectUri, idp }) {
718
- const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
719
- method: 'POST',
720
- headers: {
721
- 'Content-Type': 'application/json',
722
- Accept: 'application/json',
723
- 'User-Agent': userAgent(),
724
- },
725
- body: JSON.stringify({
726
- client_id: idp.clientId,
727
- client_secret: idp.clientSecret,
728
- code,
729
- redirect_uri: redirectUri,
730
- }),
731
- });
732
- if (!res.ok) {
733
- const text = await res.text().catch(() => '');
734
- throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
735
- }
736
- const data = await res.json();
737
- if (!data || data.github_id == null) {
738
- throw new Error('hub /sso/token returned no identity');
739
- }
740
- return data;
741
- }
742
-
743
- // Complete the instance web sign-in via the hub. Sources identity from the hub's
744
- // /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
745
- // maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
746
- // identity SOURCE differs (the hub, not github.com). The shared PURE gate
747
- // (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
748
- // path (SR-19). Returns the same { token, builder, expiresAt } shape.
749
- async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
750
- const idp = loadIdpConfig();
751
- if (!idp) throw new Error('platform IdP not configured');
752
- const identity = await exchangeIdpCode({ code, redirectUri, idp });
753
- const ghId = identity.github_id;
754
- const ghLogin = identity.github_login;
755
- const existing = await getBuilderByGithubId(ghId);
756
- if (!existing && !openEnrollmentEnabled()) {
757
- const approved = await hasInvitedAccessRequest(ghLogin);
758
- if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
759
- const err = new Error('not_approved');
760
- err.code = 'NOT_APPROVED';
761
- throw err;
762
- }
763
- }
764
- let builder = await upsertBuilder({
765
- githubId: ghId,
766
- githubLogin: ghLogin,
767
- displayName: identity.display_name || ghLogin,
768
- avatarUrl: identity.avatar_url,
769
- });
770
- builder = await maybeSeatFirstAdmin(builder);
771
- await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
772
- const token = newSessionToken();
773
- const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
774
- await createSession({
775
- builderId: builder.id,
776
- token,
777
- source: 'web',
778
- userAgent: ua ?? null,
779
- expiresAt,
780
- });
781
- await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
782
- // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
783
- // federated web sign-in. The rollup is CHAINED after the check-in (ADR 0171 D7):
784
- // the hub now requires a membership row before accepting activity, so a first
785
- // sign-in's rollup must not race its own check-in. Both swallow all errors, so
786
- // the chain never rejects and the sign-in never waits on it.
787
- // Both helpers are async (always return a promise) and swallow their own
788
- // errors; the .catch is belt-and-braces so this floating chain can never
789
- // surface an unhandledRejection even if a future edit lets one throw.
790
- reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url })
791
- .then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
792
- .catch(() => {});
793
- return { token, builder, expiresAt };
794
- }
795
-
796
- // -------------------------------------------------------------------------
797
- // Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
798
- // assertion OFFLINE and mints its own cli session. The CLI runs the device flow
799
- // against the HUB (GitHub device codes can't cross apps), the hub signs an
800
- // audience-scoped Ed25519 assertion, and the instance verifies it here with the
801
- // hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
802
- // -------------------------------------------------------------------------
803
-
804
- const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
805
-
806
- // The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
807
- // instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
808
- function loadHubPublicKey() {
809
- const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
810
- if (!b64) return null;
811
- try {
812
- return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
813
- } catch (err) {
814
- console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
815
- return null;
816
- }
817
- }
818
-
819
- // Verify a hub-signed assertion (the compact-JWT format minted by the hub's
820
- // platform-identity/hub-keys.js — kept in lockstep across the core<->module
821
- // boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
822
- // public key), then the issuer, audience (must equal THIS instance's client_id),
823
- // and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
824
- // regardless of the header's `alg` field, so there is no algorithm-confusion attack.
825
- // Returns the validated payload, or throws. Pure over its inputs (testable).
826
- function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
827
- if (!publicKey) throw new Error('hub public key not configured');
828
- const parts = String(token || '').split('.');
829
- if (parts.length !== 3) throw new Error('malformed assertion');
830
- const [h, p, s] = parts;
831
- let header;
832
- try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
833
- catch { throw new Error('malformed assertion header'); }
834
- if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
835
- // Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
836
- const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
837
- if (!ok) throw new Error('assertion signature invalid');
838
- let payload;
839
- try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
840
- catch { throw new Error('malformed assertion payload'); }
841
- if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
842
- if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
843
- if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
844
- if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
845
- return payload;
846
- }
847
-
848
- // The purpose marker on a hub back-channel logout token — kept in lockstep with
849
- // the hub minter (platform-identity/hub-keys.js LOGOUT_PURPOSE) across the
850
- // boundary (ADR 0091 §2). Task 1002238.
851
- const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
852
-
853
- // Verify a hub-signed BACK-CHANNEL LOGOUT token (ADR 0144, task 1002238) — the
854
- // logout sibling of verifyHubAssertion, same compact-EdDSA-JWT envelope, kept in
855
- // lockstep with the hub minter (hub-keys.js mintHubLogoutToken). Verifies the
856
- // Ed25519 signature FIRST (alg HARDCODED to Ed25519 regardless of the header, so
857
- // no algorithm-confusion), then issuer, audience (must equal THIS instance's
858
- // client_id, so a token minted for another project can't be replayed here),
859
- // expiry, and that purpose === 'backchannel-logout'. Requiring `purpose` is what
860
- // makes login assertions and logout tokens mutually non-interchangeable: a login
861
- // assertion has no purpose (rejected here), and a logout token has no
862
- // github_login (rejected by verifyHubAssertion). Returns the validated payload
863
- // ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
864
- function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
865
- if (!publicKey) throw new Error('hub public key not configured');
866
- const parts = String(token || '').split('.');
867
- if (parts.length !== 3) throw new Error('malformed logout token');
868
- const [h, p, s] = parts;
869
- let header;
870
- try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
871
- catch { throw new Error('malformed logout token header'); }
872
- if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
873
- // Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
874
- const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
875
- if (!ok) throw new Error('logout token signature invalid');
876
- let payload;
877
- try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
878
- catch { throw new Error('malformed logout token payload'); }
879
- if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
880
- if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
881
- if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
882
- if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
883
- if (payload.sub == null) throw new Error('logout token missing subject');
884
- return payload;
885
- }
886
-
887
- // Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
888
- // aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
889
- // assertion's own expiry, so a captured assertion can't be replayed into multiple
890
- // sessions even inside its ~120s life. A restart clears it — acceptable given the
891
- // short window and that the aud binding is the primary control.
892
- const _seenAssertionJti = new Map(); // jti -> expiry ms
893
- function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
894
- for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
895
- if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
896
- if (_seenAssertionJti.has(jti)) return true;
897
- _seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
898
- return false;
899
- }
900
-
901
- // Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
902
- // assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
903
- // a replay within the ~120s TTL is a no-op. Without this, a captured token stays a
904
- // repeatable "log this builder out" primitive for its whole life — it would keep
905
- // killing sessions the builder legitimately creates AFTER the token was minted
906
- // (the one non-idempotent replay effect; revoking already-dead sessions is
907
- // otherwise harmless). Separate map from assertions so the two token types never
908
- // share dedupe state. In-memory per process; a restart clears it (acceptable — the
909
- // window is short and the aud binding is the primary control). Exported for the
910
- // route (routes/auth.js) since the DB revoke lives there, not in the kernel.
911
- const _seenLogoutJti = new Map(); // jti -> expiry ms
912
- function logoutTokenJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
913
- for (const [k, exp] of _seenLogoutJti) if (exp < nowMs) _seenLogoutJti.delete(k);
914
- if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
915
- if (_seenLogoutJti.has(jti)) return true;
916
- _seenLogoutJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
917
- return false;
918
- }
919
-
920
- // Complete a CLI federation exchange: verify the hub assertion, then run the SAME
921
- // admission gate + upsertBuilder + createSession(source='cli') as the direct device
922
- // flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
923
- async function completeDeviceExchange({ assertion, userAgent: ua }) {
924
- const idp = loadIdpConfig();
925
- if (!idp) throw new Error('platform IdP not configured');
926
- const publicKey = loadHubPublicKey();
927
- if (!publicKey) throw new Error('hub public key not configured');
928
- const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
929
- // Reject a replayed assertion (same jti) within its window.
930
- if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
931
- throw new Error('assertion already used');
932
- }
933
- const ghId = payload.sub;
934
- const ghLogin = payload.github_login;
935
- const existing = await getBuilderByGithubId(ghId);
936
- if (!existing && !openEnrollmentEnabled()) {
937
- const approved = await hasInvitedAccessRequest(ghLogin);
938
- if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
939
- const err = new Error('not_approved');
940
- err.code = 'NOT_APPROVED';
941
- throw err;
942
- }
943
- }
944
- let builder = await upsertBuilder({
945
- githubId: ghId,
946
- githubLogin: ghLogin,
947
- displayName: payload.display_name || ghLogin,
948
- avatarUrl: payload.avatar_url,
949
- });
950
- builder = await maybeSeatFirstAdmin(builder);
951
- await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
952
- const token = newSessionToken();
953
- await createSession({
954
- builderId: builder.id,
955
- token,
956
- source: 'cli',
957
- userAgent: ua ?? null,
958
- expiresAt: null,
959
- });
960
- await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
961
- // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) —
962
- // federated CLI sign-in. Rollup chained after check-in (ADR 0171 D7): the hub's
963
- // membership requirement must not race a first sign-in; both swallow errors.
964
- // Async + self-swallowing helpers; .catch is belt-and-braces (see web flow).
965
- reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url })
966
- .then(() => reportActivityRollup({ builderId: builder.id, githubId: ghId }))
967
- .catch(() => {});
968
- return { token, builder };
969
- }
970
-
971
- // -------------------------------------------------------------------------
972
- // Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
973
- // -------------------------------------------------------------------------
974
- // Links a Discord account to an ALREADY-AUTHENTICATED builder. Client creds
975
- // come from env (DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET) or, for local dev,
976
- // ~/.config/otb/discord-bot.json — same env-first-then-file shape as GitHub.
977
- // scope=identify (the Discord user id, for the link) + guilds.join (so the
978
- // server can drop the just-linked builder straight into the guild — the
979
- // "link-first auto-join" onboarding gate, F7/ADR 0033: a builder approves the
980
- // link BEFORE entering, so their rank role is already correct on arrival; there
981
- // is no invite link to share or leak). guilds.join is only USABLE alongside the
982
- // bot adding the member, and only if the bot holds CREATE_INSTANT_INVITE — see
983
- // addDiscordGuildMember + docs/discord/role-sync.md. The pure helpers
984
- // (discordAuthorizeUrl, isValidDiscordUserId) are exported for unit testing
985
- // without creds or network.
986
- const DISCORD_SCOPE = 'identify guilds.join';
987
-
988
- function loadDiscordCreds() {
989
- const clientId = process.env.DISCORD_CLIENT_ID;
990
- const clientSecret = process.env.DISCORD_CLIENT_SECRET;
991
- if (clientId && clientSecret) return { client_id: clientId, client_secret: clientSecret };
992
- try {
993
- const raw = ic.readConfigFileSync('discord-bot.json');
994
- if (raw) {
995
- const creds = JSON.parse(raw);
996
- if (creds.client_id && creds.client_secret) {
997
- return { client_id: creds.client_id, client_secret: creds.client_secret };
998
- }
999
- }
1000
- } catch (_) { /* not configured */ }
1001
- return null;
1002
- }
1003
-
1004
- function discordConfigured() {
1005
- return !!loadDiscordCreds();
1006
- }
1007
-
1008
- // Bot token loader (Phase 3 F3, ADR 0033 §F3). The bot authenticates to the
1009
- // Discord GATEWAY with a bot token — distinct from the OAuth client_id/secret
1010
- // used for account-linking (F2). Same env-first-then-file shape as everything
1011
- // else: env DISCORD_BOT_TOKEN wins (the systemd path ADR 0022 prefers for the
1012
- // always-on droplet process), else the bot_token field of the local
1013
- // ~/.config/otb/discord-bot.json file (already on the droplet, mode 600). The
1014
- // token is a secret — never log it, never return it anywhere user-facing.
1015
- function loadDiscordBotToken() {
1016
- const envToken = process.env.DISCORD_BOT_TOKEN;
1017
- if (envToken) return envToken;
1018
- try {
1019
- const raw = ic.readConfigFileSync('discord-bot.json');
1020
- if (raw) {
1021
- const creds = JSON.parse(raw);
1022
- if (creds.bot_token) return creds.bot_token;
1023
- }
1024
- } catch (_) { /* not configured */ }
1025
- return null;
1026
- }
1027
-
1028
- function discordBotConfigured() {
1029
- return !!loadDiscordBotToken();
1030
- }
1031
-
1032
- // PURE — build the Discord authorize URL (testable without creds/network).
1033
- function discordAuthorizeUrl(clientId, state, redirectUri) {
1034
- const u = new URL('https://discord.com/api/oauth2/authorize');
1035
- u.searchParams.set('client_id', clientId);
1036
- u.searchParams.set('redirect_uri', redirectUri);
1037
- u.searchParams.set('response_type', 'code');
1038
- u.searchParams.set('scope', DISCORD_SCOPE);
1039
- u.searchParams.set('state', state);
1040
- return u.toString();
1041
- }
1042
-
1043
- function buildDiscordAuthorizeUrl(state, redirectUri) {
1044
- const creds = loadDiscordCreds();
1045
- if (!creds) throw new Error('Discord OAuth credentials not configured');
1046
- return discordAuthorizeUrl(creds.client_id, state, redirectUri);
1047
- }
1048
-
1049
- async function exchangeDiscordCode(code, redirectUri) {
1050
- const creds = loadDiscordCreds();
1051
- if (!creds) throw new Error('Discord OAuth credentials not configured');
1052
- const res = await fetch('https://discord.com/api/oauth2/token', {
1053
- method: 'POST',
1054
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1055
- body: new URLSearchParams({
1056
- client_id: creds.client_id,
1057
- client_secret: creds.client_secret,
1058
- grant_type: 'authorization_code',
1059
- code,
1060
- redirect_uri: redirectUri,
1061
- }).toString(),
1062
- });
1063
- if (!res.ok) {
1064
- const text = await res.text();
1065
- throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
1066
- }
1067
- const data = await res.json();
1068
- return data.access_token;
1069
- }
1070
-
1071
- async function fetchDiscordUser(accessToken) {
1072
- const res = await fetch('https://discord.com/api/users/@me', {
1073
- headers: { Authorization: `Bearer ${accessToken}` },
1074
- });
1075
- if (!res.ok) {
1076
- const text = await res.text();
1077
- throw new Error(`Discord /users/@me failed (${res.status}): ${text}`);
1078
- }
1079
- return res.json(); // { id, username, ... }
1080
- }
1081
-
1082
- // Kick a member from the guild when they unlink their Discord account (F7
1083
- // follow-up). Uses the bot token only — no user OAuth needed for a kick.
1084
- // Requires the bot to hold Kick Members. Returns { removed: true } on 204
1085
- // (success) or { removed: false, reason } when the member wasn't in the guild
1086
- // (404) or the bot lacks permission (403) — both non-fatal for an unlink.
1087
- // Throws on unexpected errors so the caller can log; unlink itself must still
1088
- // succeed even if the kick can't.
1089
- async function removeDiscordGuildMember({ guildId, discordUserId }) {
1090
- const botToken = loadDiscordBotToken();
1091
- if (!botToken) throw new Error('Discord bot token not configured');
1092
- if (!guildId) throw new Error('Discord guild id not configured');
1093
- const res = await fetch(`https://discord.com/api/guilds/${guildId}/members/${discordUserId}`, {
1094
- method: 'DELETE',
1095
- headers: { Authorization: `Bot ${botToken}` },
1096
- });
1097
- if (res.status === 204) return { removed: true };
1098
- if (res.status === 404) return { removed: false, reason: 'not_in_guild' };
1099
- if (res.status === 403) return { removed: false, reason: 'missing_permission' };
1100
- const text = await res.text();
1101
- throw new Error(`Discord guild remove failed (${res.status}): ${text}`);
1102
- }
1103
-
1104
- // PURE — a Discord user id is a numeric snowflake (17–20 digits). Reject
1105
- // anything else BEFORE it reaches db.linkDiscord (F1 Hacker advisory).
1106
- function isValidDiscordUserId(id) {
1107
- return typeof id === 'string' && /^\d{17,20}$/.test(id);
1108
- }
1109
-
1110
- // Add the just-linked builder straight into the guild (F7, ADR 0033 — the
1111
- // "link-first auto-join" gate). Uses the user's own OAuth access token (granted
1112
- // via the guilds.join scope) together with the BOT token — Discord's add-member
1113
- // endpoint requires both: the user consent (access_token) and a bot with the
1114
- // CREATE_INSTANT_INVITE permission. `roleIds` are applied at add-time so the
1115
- // member's rank badge is present the instant they arrive (Discord only honors
1116
- // the roles array when CREATING the membership — for an already-present member
1117
- // it 204s and ignores them, which is why the callback also runs syncBuilderRole
1118
- // belt-and-braces). Returns { added } — 201 = newly added, 204 = already a
1119
- // member. Throws on any other status so the caller can log; auto-join is
1120
- // best-effort and must never break the link itself.
1121
- async function addDiscordGuildMember({ guildId, discordUserId, accessToken, roleIds }) {
1122
- const botToken = loadDiscordBotToken();
1123
- if (!botToken) throw new Error('Discord bot token not configured');
1124
- if (!guildId) throw new Error('Discord guild id not configured');
1125
- const res = await fetch(`https://discord.com/api/guilds/${guildId}/members/${discordUserId}`, {
1126
- method: 'PUT',
1127
- headers: { Authorization: `Bot ${botToken}`, 'Content-Type': 'application/json' },
1128
- body: JSON.stringify({ access_token: accessToken, roles: Array.isArray(roleIds) ? roleIds : [] }),
1129
- });
1130
- if (res.status === 201 || res.status === 204) return { added: res.status === 201 };
1131
- const text = await res.text();
1132
- throw new Error(`Discord guild add failed (${res.status}): ${text}`);
1133
- }
1134
-
1135
- // -------------------------------------------------------------------------
1136
- // Express middleware: authenticate by Bearer token (CLI) or gds_session cookie
1137
- // (web). Legacy pms_session cookie still accepted on read so a builder mid-
1138
- // session at the V3.R01 rollout doesn't have to re-auth.
1139
- // -------------------------------------------------------------------------
1140
-
1141
- // Collect EVERY candidate auth token on the request, in priority order, with
1142
- // duplicates removed. A browser can legitimately carry MORE THAN ONE
1143
- // gds_session cookie: a host-only one (set on amazonprimea.com before the
1144
- // #726/#734 apex-scoping rollout) AND the current apex-scoped one
1145
- // (.amazonprimea.com). The browser sends both in the Cookie header. We must try
1146
- // them ALL — picking only the first (as the old single-value extractToken did)
1147
- // meant a stale/expired duplicate could shadow the live session and 401 the
1148
- // builder (hit on the Discord link callback for builder 25). Order: Bearer
1149
- // (CLI) first, then every gds_session value in header order, then legacy
1150
- // pms_session — so a valid current session always wins over a stale one.
1151
- // SECURITY (#871): cap the number of distinct candidate tokens we will try per
1152
- // request. resolveSession does ONE DB lookup per candidate, so an attacker who
1153
- // stuffs the Cookie header with dozens of gds_session=<junk> values could force
1154
- // dozens of DB round-trips per request (amplification / DoS) even while
1155
- // unauthenticated. A legitimate browser carries at most a Bearer + a couple of
1156
- // gds_session cookies (host-only + apex during the #734 rollout) + one legacy
1157
- // pms_session, so a small cap never hurts a real client.
1158
63
  const MAX_AUTH_TOKEN_CANDIDATES = 6;
1159
64
 
1160
65
  function extractTokens(req) {