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