@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.
Files changed (62) hide show
  1. package/.bongos-core.json +173 -43
  2. package/docs/adr/0187-oauth-handshake-cookie-lifetime.md +84 -0
  3. package/docs/adr/README.md +1 -0
  4. package/docs/api/openapi.json +2 -2
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +202 -202
  7. package/docs/copy-registry.json +384 -384
  8. package/modules/grading/grader-config.js +98 -0
  9. package/modules/grading/grader-model.js +150 -0
  10. package/modules/grading/grader-prompt.js +139 -0
  11. package/modules/grading/grader-score.js +706 -0
  12. package/modules/grading/grader-subagent.js +328 -0
  13. package/modules/grading/grader.js +5 -1287
  14. package/modules/hall-ui/public/builders.js +19 -1182
  15. package/modules/hall-ui/public/goals-page.js +11 -997
  16. package/modules/hall-ui/public/goals-render.js +1032 -0
  17. package/modules/hall-ui/public/goals.html +4 -0
  18. package/modules/hall-ui/public/hall-render.js +1211 -0
  19. package/modules/hall-ui/public/index.html +6 -0
  20. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  21. package/modules/lifecycle/routes/goals.js +1 -367
  22. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  23. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  24. package/modules/lifecycle/routes/tasks.js +3 -696
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  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/bongos/routes/auth.js +54 -10
  46. package/src/module-api.js +5 -1
  47. package/tests/auth_admission_gate.mjs +3 -1
  48. package/tests/auth_web_recovery.mjs +63 -0
  49. package/tests/est_advisory.mjs +3 -1
  50. package/tests/goal_advisory.mjs +3 -2
  51. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  52. package/tests/goal_subtitle.mjs +5 -2
  53. package/tests/goals_card_view.mjs +10 -3
  54. package/tests/grader_generated_provenance.mjs +3 -1
  55. package/tests/hall_palette.mjs +3 -1
  56. package/tests/helpers.mjs +58 -25
  57. package/tests/lib_sh_resolution.mjs +3 -1
  58. package/tests/membership_kind_lockstep.mjs +3 -1
  59. package/tests/migration_allocation.mjs +5 -2
  60. package/tests/patch_gate_ordering.mjs +3 -1
  61. package/tests/rank_tier_single_source.mjs +16 -3
  62. package/tests/reward_gate.mjs +3 -2
@@ -0,0 +1,264 @@
1
+ // src/bongos/auth-admission.js — who is allowed IN: the membership kind reported
2
+ // to the hub, the activity rollup, open enrollment, the first-time admission
3
+ // block, first-admin seating and the rank-role sync.
4
+ //
5
+ // membershipKind lives here rather than with the config readers because it calls
6
+ // firstAdminLogin() — putting it the other side of the seam would need a cycle.
7
+ //
8
+ // Carved out of src/bongos/auth.js by task 1003206 (goal 1000079, the R03 plan)
9
+ // to bring the kernel identity file under the 1,500-line ratchet. This is the
10
+ // highest-risk file in the carve, so the move is STRICTLY structural: bodies
11
+ // unchanged, auth.js requires every name back, and the middleware surface
12
+ // (requireBuilder / requireRank / requirePermission / the page gates) never left.
13
+
14
+ 'use strict';
15
+
16
+ const {
17
+ upsertBuilder,
18
+ seatFirstAdminIfUnclaimed,
19
+ createSession,
20
+ lookupSession,
21
+ getBuilderById,
22
+ getBuilderByGithubId,
23
+ hasInvitedAccessRequest,
24
+ accessStateByLogin,
25
+ RANKS,
26
+ } = require('./db-kernel');
27
+ const { pool } = require('./pool');
28
+ const seams = require('../module-seams');
29
+ const ic = require('../instance-config');
30
+ const { branding, userAgent } = require('../branding');
31
+ const { loadIdpConfig } = require('./auth-config.js');
32
+
33
+ function membershipKind({ githubLogin } = {}) {
34
+ const fa = firstAdminLogin();
35
+ if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
36
+ return 'member';
37
+ }
38
+
39
+ // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
40
+ // github_id builds on project <client_id>, coarse membership <membership_kind>".
41
+ //
42
+ // BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
43
+ // HUB, a different instance on its own upgrade schedule: send only the new field and
44
+ // an un-upgraded hub silently defaults every membership to non-owner (it would drop
45
+ // the owner flag); send only the legacy field and an upgraded hub keeps translating
46
+ // forever. Sending both is what makes the two ends independent — the removal task
47
+ // drops `role` once the hub is past the window. Runs on every
48
+ // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
49
+ // project still appears in the builder's My Projects with no hub browser session.
50
+ // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
51
+ // the onboarding stage tick). No-op on a self-hosted instance (idp unset).
52
+ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
53
+ const idp = loadIdpConfig();
54
+ if (!idp) return;
55
+ const kind = membershipKind({ githubLogin });
56
+ try {
57
+ await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
58
+ method: 'POST',
59
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
60
+ body: JSON.stringify({
61
+ client_id: idp.clientId,
62
+ client_secret: idp.clientSecret,
63
+ github_id: githubId,
64
+ github_login: githubLogin,
65
+ display_name: displayName ?? null,
66
+ avatar_url: avatarUrl ?? null,
67
+ membership_kind: kind,
68
+ // LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
69
+ role: kind === 'member' ? 'builder' : kind,
70
+ }),
71
+ });
72
+ } catch (err) {
73
+ console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
74
+ }
75
+ }
76
+
77
+ // Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
78
+ // builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
79
+ // for the internal scouting directory. Fires on every federated sign-in ("on key
80
+ // events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
81
+ // not federated — never fails a sign-in.
82
+ async function reportActivityRollup({ builderId, githubId }) {
83
+ const idp = loadIdpConfig();
84
+ if (!idp) return;
85
+ try {
86
+ // Snapshot the builder's talent signals directly from the DB (auth.js is a
87
+ // kernel file — it uses pool, never a non-kernel domain module like metrics.js).
88
+ const b = await pool.query(
89
+ 'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
90
+ [builderId],
91
+ );
92
+ if (!b.rows[0]) return;
93
+ const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
94
+ const row = b.rows[0];
95
+ await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
96
+ method: 'POST',
97
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
98
+ body: JSON.stringify({
99
+ client_id: idp.clientId,
100
+ client_secret: idp.clientSecret,
101
+ github_id: githubId,
102
+ credits: Number(row.total_credits) || 0,
103
+ tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
104
+ karma: Number(row.karma) || 0,
105
+ rank: row.rank || null,
106
+ disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
107
+ }),
108
+ });
109
+ } catch (err) {
110
+ console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
111
+ }
112
+ }
113
+
114
+ // Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
115
+ // sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
116
+ // "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
117
+ // Archon marked their access_request 'invited'. An EXISTING builder (a row
118
+ // already exists for their github_id) ALWAYS passes — the gate never locks out
119
+ // someone already enrolled.
120
+ //
121
+ // OTB_OPEN_XENOS_ENROLLMENT is the escape-hatch: set it truthy ('1' or 'true')
122
+ // to restore fully-open self-enrollment (anyone with a GitHub account becomes a
123
+ // Xenos) WITHOUT a code change, for an owner who deliberately wants open
124
+ // enrollment. Default (unset/anything-else) = invite required. See ADR 0050.
125
+ function openEnrollmentEnabled() {
126
+ const v = ic.resolveEnv('OPEN_XENOS_ENROLLMENT');
127
+ return v === '1' || v === 'true';
128
+ }
129
+
130
+ // PURE admission decision shared by both flows so they CANNOT drift (SR-19).
131
+ // Returns true when a first-time account must be blocked. `existing` is the
132
+ // builder row (or null) from getBuilderByGithubId; `approved` is the result of
133
+ // hasInvitedAccessRequest. Blocks only when: not already a builder AND open
134
+ // enrollment is off AND not invite-approved. Exported for direct unit testing.
135
+ function firstTimeAdmissionBlocked({ existing, approved, login }) {
136
+ if (existing || openEnrollmentEnabled() || approved) return false;
137
+ // The configured firstAdmin is always admitted on a fresh instance so the
138
+ // bootstrap (maybeSeatFirstAdmin) can seat them as the first Archon — the owner
139
+ // must not be gated out of their own new instance when no Archon exists yet to
140
+ // approve them (the chicken-and-egg R62 / ADR 0062 §6 left open).
141
+ // seatFirstAdminIfUnclaimed is race-safe and only fires while NO Archon exists,
142
+ // so this never escalates an account on an established instance; it only lets
143
+ // the configured owner login through admission.
144
+ const fa = firstAdminLogin();
145
+ if (fa && String(login || '').toLowerCase() === fa.toLowerCase()) return false;
146
+ return true;
147
+ }
148
+
149
+ // webAdmissionStatus — "would this login get in RIGHT NOW, and is it worth
150
+ // waiting?" (task 1002097). Backs the waiting page that replaced the red
151
+ // "not approved" dead-end a firstAdmin hits during the bootstrap window.
152
+ //
153
+ // `admitted` deliberately mirrors firstTimeAdmissionBlocked's own terms —
154
+ // member OR open-enrollment OR invited OR configured-firstAdmin — so the probe
155
+ // can never say "come in" while the real gate says otherwise. It reads state;
156
+ // it decides nothing. firstTimeAdmissionBlocked remains the one gate both flows
157
+ // call (SR-19); if that logic changes, this must change with it.
158
+ // tests/web_admission_status.mjs sweeps the input space asserting the two agree.
159
+ //
160
+ // `expectedSoon` is the broader "keep polling" signal: admitted, or waiting on
161
+ // an Archon (pending), or the configured firstAdmin whose instance is still
162
+ // booting. A visitor who is none of these is a genuine stranger and gets the
163
+ // existing request-access page rather than a spinner that never resolves.
164
+ //
165
+ // NB the onboarding module's public GET /access-requests/status (used by the
166
+ // CLI) reports member|invited|pending|none but knows nothing about the
167
+ // firstAdmin bypass or open enrollment — so it reports the OWNER as 'none'
168
+ // forever. That is exactly why this auth-domain probe exists.
169
+ async function webAdmissionStatus(login) {
170
+ const name = String(login || '').trim();
171
+ if (!name) return { admitted: false, expectedSoon: false };
172
+
173
+ const fa = firstAdminLogin();
174
+ const isFirstAdmin = !!fa && name.toLowerCase() === fa.toLowerCase();
175
+
176
+ let state = { member: false, invited: false, pending: false };
177
+ try {
178
+ state = await accessStateByLogin(name);
179
+ } catch (err) {
180
+ // A DB hiccup must not turn the waiting page into a false "you're in".
181
+ // Fail closed on `admitted`; still let a firstAdmin keep polling, since
182
+ // that is the bootstrap window this page exists for.
183
+ console.error('[auth] webAdmissionStatus', err);
184
+ return { admitted: false, expectedSoon: isFirstAdmin };
185
+ }
186
+
187
+ const admitted = state.member || openEnrollmentEnabled() || state.invited || isFirstAdmin;
188
+ return { admitted, expectedSoon: admitted || state.pending || isFirstAdmin };
189
+ }
190
+
191
+ // First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
192
+ // login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
193
+ // else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
194
+ // auto-seated"; the owner sets it when scaffolding the instance. Trimmed; '' → null.
195
+ function firstAdminLogin() {
196
+ let login = ic.resolveEnv('FIRST_ADMIN');
197
+ if (login === undefined) {
198
+ try { login = branding().firstAdmin; } catch { login = undefined; }
199
+ }
200
+ login = (login || '').trim();
201
+ return login || null;
202
+ }
203
+
204
+ // Seat the configured firstAdmin as Archon at their first enrollment, but only
205
+ // while NO Archon exists yet (the db guard is atomic + race-safe, so this can
206
+ // never escalate an account once an owner exists). Best-effort: a bootstrap
207
+ // hiccup must never fail the sign-in. Returns the builder, with rank updated to
208
+ // 'archon' on the returned object if this call seated it.
209
+ async function maybeSeatFirstAdmin(builder) {
210
+ const configured = firstAdminLogin();
211
+ if (!configured || !builder || builder.rank === 'archon') return builder;
212
+ if (String(builder.github_login || '').toLowerCase() !== configured.toLowerCase()) return builder;
213
+ try {
214
+ const seated = await seatFirstAdminIfUnclaimed(builder.id, builder.github_login);
215
+ if (seated) {
216
+ console.log(`[auth] first-admin bootstrap: seated ${builder.github_login} as Archon (configured firstAdmin, no prior Archon).`);
217
+ return { ...builder, rank: 'archon' };
218
+ }
219
+ } catch (err) {
220
+ console.error('[auth] first-admin bootstrap failed (non-fatal):', err.message);
221
+ }
222
+ return builder;
223
+ }
224
+
225
+ // safeSyncRankRole — give a builder the seeded rank-role matching their rank at
226
+ // REGISTRATION (BV1.R95b, ADR 0151). governance_002's builder→role backfill is
227
+ // run-once, so before this every builder admitted after that migration had no role
228
+ // row at all and resolved to the EMPTY permission set — denied on every gated route.
229
+ //
230
+ // Called at each sign-in rather than only on first insert, and deliberately so: the
231
+ // call is idempotent (two SELECTs and no write once the role already matches), it
232
+ // also covers the rank write maybeSeatFirstAdmin just made (the firstAdmin bootstrap
233
+ // flips xenos→archon outside any transaction), and it quietly repairs a builder whose
234
+ // assignment drifted for any other reason.
235
+ //
236
+ // Resolved through the `government.rankSync` kernel port — auth.js is a KERNEL_FILE
237
+ // and may not require a module (ADR 0083 / ADR 0091 §1). Runs on the pool: there is
238
+ // no surrounding transaction here, unlike the two rank-change paths, which pass their
239
+ // own client so the rotation is atomic with the rank write.
240
+ //
241
+ // BEST-EFFORT: a governance hiccup must never fail a sign-in, and the failure mode is
242
+ // fail-closed anyway — no role means zero permissions, never extra ones.
243
+ async function safeSyncRankRole(builder) {
244
+ if (!builder || builder.id == null) return;
245
+ try {
246
+ await seams.resolveOptional('government.rankSync')
247
+ ?.syncRank(null, { builderId: builder.id, rank: builder.rank });
248
+ } catch (err) {
249
+ console.error('[auth] rank-role sync failed (non-fatal):', err.message);
250
+ }
251
+ }
252
+
253
+
254
+ module.exports = {
255
+ firstAdminLogin,
256
+ firstTimeAdmissionBlocked,
257
+ maybeSeatFirstAdmin,
258
+ membershipKind,
259
+ openEnrollmentEnabled,
260
+ reportActivityRollup,
261
+ reportMembershipCheckIn,
262
+ safeSyncRankRole,
263
+ webAdmissionStatus,
264
+ };
@@ -0,0 +1,151 @@
1
+ // src/bongos/auth-config.js — what identity providers are configured and which
2
+ // OAuth scopes to ask for: GitHub creds + scope selection and the hub IdP config.
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 ic = require('../instance-config');
13
+ const { branding, userAgent } = require('../branding');
14
+
15
+ const SCOPE = 'read:user';
16
+
17
+ // The repo-acting scope the onboarding wizard opts into. `repo`, NOT `public_repo`.
18
+ //
19
+ // ADR 0143 §1 originally chose `public_repo` precisely to avoid the all-or-nothing
20
+ // `repo`, and named its own reversal path: "revisit to `repo` only if private
21
+ // support is needed". It is needed. `public_repo` grants write to PUBLIC repos
22
+ // only, so ADOPTING an existing PRIVATE repo dies at the provisioner's scaffold
23
+ // push with GitHub 403 "Write access to repository not granted" — observed live on
24
+ // a private repo of the owner's. ADR 0155 takes the reversal at the owner's
25
+ // explicit call, accepting the cost it names: a token we persist can now write to
26
+ // EVERY repo the user owns, not just their public ones.
27
+ const REPO_SCOPE = 'repo';
28
+
29
+ // Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
30
+ // and the CLI Device Flow ever need. The onboarding wizard
31
+ // (public-landing/create.html), however, lists + creates + is pushed to by the
32
+ // provisioner on the signed-in user's own GitHub repos (task 1002099) — which
33
+ // `read:user` cannot do — so it OPTS IN to REPO_SCOPE. Only the wizard passes
34
+ // { repos:true }; every other sign-in stays identity-only. Pure + exported so the
35
+ // exact scope string (a security-critical value) is unit-testable.
36
+ function scopeForWebFlow({ repos = false } = {}) {
37
+ return repos ? `${SCOPE} ${REPO_SCOPE}` : SCOPE;
38
+ }
39
+
40
+ // Does a scope string GitHub ACTUALLY GRANTED still satisfy the repo surfaces?
41
+ //
42
+ // WHY THIS EXISTS. Widening scopeForWebFlow does NOT upgrade tokens already in
43
+ // `github_oauth_tokens` — a row minted before ADR 0155 carries `public_repo`, which
44
+ // is enough to list and create PUBLIC repos. So every wizard call would still 200
45
+ // while silently omitting the user's PRIVATE repos from the picker, and the failure
46
+ // would resurface far downstream at the provisioner's push. Callers check this and
47
+ // re-prompt (repo_scope_required) instead of degrading quietly.
48
+ //
49
+ // GitHub returns granted scopes comma-separated in its own order (e.g.
50
+ // "public_repo,read:user"), so parse rather than substring-match — `public_repo`
51
+ // CONTAINS "repo" and a naive match would accept exactly the stale token this
52
+ // guards against. `repo` implies `public_repo`, so `repo` alone is sufficient.
53
+ function grantedScopeCoversRepos(granted) {
54
+ return String(granted || '')
55
+ .split(/[,\s]+/)
56
+ .map((s) => s.trim())
57
+ .includes(REPO_SCOPE);
58
+ }
59
+
60
+ // In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
61
+ // Device flow is ephemeral; restart of the Node process invalidates pending flows
62
+ // (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
63
+ // moving this to Redis or a tiny `device_flows` table.
64
+ const deviceFlows = new Map();
65
+
66
+ function loadGithubCreds() {
67
+ // Resolve the configured instance prefix first (CLOUDBONGOS_ on a fresh
68
+ // instance, OTB_ on this one), then the legacy GDS_/PMS_ names — so the
69
+ // droplet's EnvironmentFile rename can lag the code deploy without breaking
70
+ // auth (R61 / task 1200).
71
+ const clientId = ic.resolveEnv('GITHUB_CLIENT_ID');
72
+ const clientSecret = ic.resolveEnv('GITHUB_CLIENT_SECRET');
73
+ if (clientId && clientSecret) {
74
+ return { client_id: clientId, client_secret: clientSecret };
75
+ }
76
+ // Local-dev fallback: try the new filename first, then the legacy one — each
77
+ // across the configured config dir and the legacy ~/.config/otb dir.
78
+ for (const name of ['gds-github.json', 'pms-github.json']) {
79
+ try {
80
+ const raw = ic.readConfigFileSync(name);
81
+ if (raw) {
82
+ const creds = JSON.parse(raw);
83
+ if (creds.client_id && creds.client_secret) return creds;
84
+ }
85
+ } catch (_) {
86
+ /* try next */
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function authConfigured() {
93
+ return !!loadGithubCreds();
94
+ }
95
+
96
+ // -------------------------------------------------------------------------
97
+ // Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
98
+ // CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
99
+ // directly. Identity is federated; authority is NOT — the instance still runs its
100
+ // OWN admission gate + upsertBuilder + createSession, so the hub only answers
101
+ // "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
102
+ // unchanged fallback for self-hosted instances.
103
+ // -------------------------------------------------------------------------
104
+
105
+ // The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
106
+ // false so a branding hiccup degrades to the direct-GitHub path rather than
107
+ // breaking sign-in.
108
+ function idpEnabled() {
109
+ try { return branding().auth?.idp === true; } catch { return false; }
110
+ }
111
+
112
+ // Resolve the hub federation config. The switch is the flag above; the hub origin
113
+ // + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
114
+ // _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
115
+ // branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
116
+ // (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
117
+ // flag is on AND all three resolve → callers fall back to direct GitHub.
118
+ function loadIdpConfig() {
119
+ if (!idpEnabled()) return null;
120
+ const origin = ic.resolveEnv('HUB_ORIGIN');
121
+ const clientId = ic.resolveEnv('HUB_CLIENT_ID');
122
+ const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
123
+ if (origin && clientId && clientSecret) {
124
+ return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
125
+ }
126
+ return null;
127
+ }
128
+
129
+ function idpConfigured() {
130
+ return !!loadIdpConfig();
131
+ }
132
+
133
+ // The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
134
+ // builder is the instance's configured firstAdmin, else a plain member. NEVER a rank.
135
+ //
136
+ // 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
137
+ // `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
138
+ // project membership says what it means. The legacy spelling still goes on the wire for
139
+ // the cross-version window; see reportMembershipCheckIn.
140
+
141
+ module.exports = {
142
+ SCOPE,
143
+ authConfigured,
144
+ deviceFlows,
145
+ grantedScopeCoversRepos,
146
+ idpConfigured,
147
+ idpEnabled,
148
+ loadGithubCreds,
149
+ loadIdpConfig,
150
+ scopeForWebFlow,
151
+ };
@@ -0,0 +1,198 @@
1
+ // src/bongos/auth-discord.js — the Discord OAuth link and guild membership calls.
2
+ //
3
+ // Carved out of src/bongos/auth.js by task 1003206 (goal 1000079, the R03 plan)
4
+ // to bring the kernel identity file under the 1,500-line ratchet. This is the
5
+ // highest-risk file in the carve, so the move is STRICTLY structural: bodies
6
+ // unchanged, auth.js requires every name back, and the middleware surface
7
+ // (requireBuilder / requireRank / requirePermission / the page gates) never left.
8
+
9
+ 'use strict';
10
+
11
+ const ic = require('../instance-config');
12
+
13
+ const DISCORD_SCOPE = 'identify guilds.join';
14
+
15
+ function loadDiscordCreds() {
16
+ const clientId = process.env.DISCORD_CLIENT_ID;
17
+ const clientSecret = process.env.DISCORD_CLIENT_SECRET;
18
+ if (clientId && clientSecret) return { client_id: clientId, client_secret: clientSecret };
19
+ try {
20
+ const raw = ic.readConfigFileSync('discord-bot.json');
21
+ if (raw) {
22
+ const creds = JSON.parse(raw);
23
+ if (creds.client_id && creds.client_secret) {
24
+ return { client_id: creds.client_id, client_secret: creds.client_secret };
25
+ }
26
+ }
27
+ } catch (_) { /* not configured */ }
28
+ return null;
29
+ }
30
+
31
+ function discordConfigured() {
32
+ return !!loadDiscordCreds();
33
+ }
34
+
35
+ // Bot token loader (Phase 3 F3, ADR 0033 §F3). The bot authenticates to the
36
+ // Discord GATEWAY with a bot token — distinct from the OAuth client_id/secret
37
+ // used for account-linking (F2). Same env-first-then-file shape as everything
38
+ // else: env DISCORD_BOT_TOKEN wins (the systemd path ADR 0022 prefers for the
39
+ // always-on droplet process), else the bot_token field of the local
40
+ // ~/.config/otb/discord-bot.json file (already on the droplet, mode 600). The
41
+ // token is a secret — never log it, never return it anywhere user-facing.
42
+ function loadDiscordBotToken() {
43
+ const envToken = process.env.DISCORD_BOT_TOKEN;
44
+ if (envToken) return envToken;
45
+ try {
46
+ const raw = ic.readConfigFileSync('discord-bot.json');
47
+ if (raw) {
48
+ const creds = JSON.parse(raw);
49
+ if (creds.bot_token) return creds.bot_token;
50
+ }
51
+ } catch (_) { /* not configured */ }
52
+ return null;
53
+ }
54
+
55
+ function discordBotConfigured() {
56
+ return !!loadDiscordBotToken();
57
+ }
58
+
59
+ // PURE — build the Discord authorize URL (testable without creds/network).
60
+ function discordAuthorizeUrl(clientId, state, redirectUri) {
61
+ const u = new URL('https://discord.com/api/oauth2/authorize');
62
+ u.searchParams.set('client_id', clientId);
63
+ u.searchParams.set('redirect_uri', redirectUri);
64
+ u.searchParams.set('response_type', 'code');
65
+ u.searchParams.set('scope', DISCORD_SCOPE);
66
+ u.searchParams.set('state', state);
67
+ return u.toString();
68
+ }
69
+
70
+ function buildDiscordAuthorizeUrl(state, redirectUri) {
71
+ const creds = loadDiscordCreds();
72
+ if (!creds) throw new Error('Discord OAuth credentials not configured');
73
+ return discordAuthorizeUrl(creds.client_id, state, redirectUri);
74
+ }
75
+
76
+ async function exchangeDiscordCode(code, redirectUri) {
77
+ const creds = loadDiscordCreds();
78
+ if (!creds) throw new Error('Discord OAuth credentials not configured');
79
+ const res = await fetch('https://discord.com/api/oauth2/token', {
80
+ method: 'POST',
81
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
82
+ body: new URLSearchParams({
83
+ client_id: creds.client_id,
84
+ client_secret: creds.client_secret,
85
+ grant_type: 'authorization_code',
86
+ code,
87
+ redirect_uri: redirectUri,
88
+ }).toString(),
89
+ });
90
+ if (!res.ok) {
91
+ const text = await res.text();
92
+ throw new Error(`Discord token exchange failed (${res.status}): ${text}`);
93
+ }
94
+ const data = await res.json();
95
+ return data.access_token;
96
+ }
97
+
98
+ async function fetchDiscordUser(accessToken) {
99
+ const res = await fetch('https://discord.com/api/users/@me', {
100
+ headers: { Authorization: `Bearer ${accessToken}` },
101
+ });
102
+ if (!res.ok) {
103
+ const text = await res.text();
104
+ throw new Error(`Discord /users/@me failed (${res.status}): ${text}`);
105
+ }
106
+ return res.json(); // { id, username, ... }
107
+ }
108
+
109
+ // Kick a member from the guild when they unlink their Discord account (F7
110
+ // follow-up). Uses the bot token only — no user OAuth needed for a kick.
111
+ // Requires the bot to hold Kick Members. Returns { removed: true } on 204
112
+ // (success) or { removed: false, reason } when the member wasn't in the guild
113
+ // (404) or the bot lacks permission (403) — both non-fatal for an unlink.
114
+ // Throws on unexpected errors so the caller can log; unlink itself must still
115
+ // succeed even if the kick can't.
116
+ async function removeDiscordGuildMember({ guildId, discordUserId }) {
117
+ const botToken = loadDiscordBotToken();
118
+ if (!botToken) throw new Error('Discord bot token not configured');
119
+ if (!guildId) throw new Error('Discord guild id not configured');
120
+ const res = await fetch(`https://discord.com/api/guilds/${guildId}/members/${discordUserId}`, {
121
+ method: 'DELETE',
122
+ headers: { Authorization: `Bot ${botToken}` },
123
+ });
124
+ if (res.status === 204) return { removed: true };
125
+ if (res.status === 404) return { removed: false, reason: 'not_in_guild' };
126
+ if (res.status === 403) return { removed: false, reason: 'missing_permission' };
127
+ const text = await res.text();
128
+ throw new Error(`Discord guild remove failed (${res.status}): ${text}`);
129
+ }
130
+
131
+ // PURE — a Discord user id is a numeric snowflake (17–20 digits). Reject
132
+ // anything else BEFORE it reaches db.linkDiscord (F1 Hacker advisory).
133
+ function isValidDiscordUserId(id) {
134
+ return typeof id === 'string' && /^\d{17,20}$/.test(id);
135
+ }
136
+
137
+ // Add the just-linked builder straight into the guild (F7, ADR 0033 — the
138
+ // "link-first auto-join" gate). Uses the user's own OAuth access token (granted
139
+ // via the guilds.join scope) together with the BOT token — Discord's add-member
140
+ // endpoint requires both: the user consent (access_token) and a bot with the
141
+ // CREATE_INSTANT_INVITE permission. `roleIds` are applied at add-time so the
142
+ // member's rank badge is present the instant they arrive (Discord only honors
143
+ // the roles array when CREATING the membership — for an already-present member
144
+ // it 204s and ignores them, which is why the callback also runs syncBuilderRole
145
+ // belt-and-braces). Returns { added } — 201 = newly added, 204 = already a
146
+ // member. Throws on any other status so the caller can log; auto-join is
147
+ // best-effort and must never break the link itself.
148
+ async function addDiscordGuildMember({ guildId, discordUserId, accessToken, roleIds }) {
149
+ const botToken = loadDiscordBotToken();
150
+ if (!botToken) throw new Error('Discord bot token not configured');
151
+ if (!guildId) throw new Error('Discord guild id not configured');
152
+ const res = await fetch(`https://discord.com/api/guilds/${guildId}/members/${discordUserId}`, {
153
+ method: 'PUT',
154
+ headers: { Authorization: `Bot ${botToken}`, 'Content-Type': 'application/json' },
155
+ body: JSON.stringify({ access_token: accessToken, roles: Array.isArray(roleIds) ? roleIds : [] }),
156
+ });
157
+ if (res.status === 201 || res.status === 204) return { added: res.status === 201 };
158
+ const text = await res.text();
159
+ throw new Error(`Discord guild add failed (${res.status}): ${text}`);
160
+ }
161
+
162
+ // -------------------------------------------------------------------------
163
+ // Express middleware: authenticate by Bearer token (CLI) or gds_session cookie
164
+ // (web). Legacy pms_session cookie still accepted on read so a builder mid-
165
+ // session at the V3.R01 rollout doesn't have to re-auth.
166
+ // -------------------------------------------------------------------------
167
+
168
+ // Collect EVERY candidate auth token on the request, in priority order, with
169
+ // duplicates removed. A browser can legitimately carry MORE THAN ONE
170
+ // gds_session cookie: a host-only one (set on amazonprimea.com before the
171
+ // #726/#734 apex-scoping rollout) AND the current apex-scoped one
172
+ // (.amazonprimea.com). The browser sends both in the Cookie header. We must try
173
+ // them ALL — picking only the first (as the old single-value extractToken did)
174
+ // meant a stale/expired duplicate could shadow the live session and 401 the
175
+ // builder (hit on the Discord link callback for builder 25). Order: Bearer
176
+ // (CLI) first, then every gds_session value in header order, then legacy
177
+ // pms_session — so a valid current session always wins over a stale one.
178
+ // SECURITY (#871): cap the number of distinct candidate tokens we will try per
179
+ // request. resolveSession does ONE DB lookup per candidate, so an attacker who
180
+ // stuffs the Cookie header with dozens of gds_session=<junk> values could force
181
+ // dozens of DB round-trips per request (amplification / DoS) even while
182
+ // unauthenticated. A legitimate browser carries at most a Bearer + a couple of
183
+ // gds_session cookies (host-only + apex during the #734 rollout) + one legacy
184
+ // pms_session, so a small cap never hurts a real client.
185
+
186
+ module.exports = {
187
+ addDiscordGuildMember,
188
+ buildDiscordAuthorizeUrl,
189
+ discordAuthorizeUrl,
190
+ discordBotConfigured,
191
+ discordConfigured,
192
+ exchangeDiscordCode,
193
+ fetchDiscordUser,
194
+ isValidDiscordUserId,
195
+ loadDiscordBotToken,
196
+ loadDiscordCreds,
197
+ removeDiscordGuildMember,
198
+ };