@bongos/core 1.17.3 → 1.18.3

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 (163) hide show
  1. package/.bongos-core.json +212 -332
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/config/branding.neutral.json +5 -1
  9. package/docker-entrypoint.sh +0 -0
  10. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  11. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  12. package/docs/adr/README.md +6 -0
  13. package/docs/api/openapi.json +1630 -632
  14. package/docs/api-reference.md +50 -15
  15. package/docs/file-map.md +0 -18
  16. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  17. package/docs/onboarding/diagrams/assertions.json +5 -9
  18. package/docs/recipes/packaging-the-core.md +13 -0
  19. package/docs/recipes/upgrading-the-core.md +5 -2
  20. package/migrations/039_newcomer_friendly_column.sql +5 -0
  21. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  22. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  23. package/modules/hall-ui/public/builders.js +37 -0
  24. package/modules/hall-ui/public/goals-page.js +98 -176
  25. package/modules/hall-ui/public/goals.css +183 -0
  26. package/modules/hall-ui/public/goals.html +35 -261
  27. package/modules/hall-ui/public/goals.js +7 -2
  28. package/modules/hall-ui/public/hall-widgets.js +12 -4
  29. package/modules/hall-ui/public/my-projects.html +78 -0
  30. package/modules/hall-ui/public/my-projects.js +142 -0
  31. package/modules/hall-ui/public/scouting.html +45 -0
  32. package/modules/hall-ui/public/scouting.js +73 -0
  33. package/modules/hall-ui/public/settings.css +355 -0
  34. package/modules/hall-ui/public/settings.html +86 -414
  35. package/modules/hall-ui/public/settings.js +96 -35
  36. package/modules/hall-ui/public/style.css +31 -0
  37. package/modules/hall-ui/public/watch.css +184 -0
  38. package/modules/hall-ui/public/watch.html +111 -285
  39. package/modules/hall-ui/public/watch.js +59 -21
  40. package/modules/status-ui/public/explore.html +54 -0
  41. package/modules/status-ui/public/explore.js +67 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/public-landing/create.html +292 -19
  45. package/public-landing/index.html +11 -76
  46. package/scripts/cron/style_guide_audit.sh +0 -0
  47. package/scripts/gds/api.js +0 -0
  48. package/scripts/gds/architect-audit.js +0 -0
  49. package/scripts/gds/audit-authorship.sh +0 -0
  50. package/scripts/gds/audit-replay.sh +0 -0
  51. package/scripts/gds/box.js +0 -0
  52. package/scripts/gds/capture.js +0 -0
  53. package/scripts/gds/cli-lib.js +11 -5
  54. package/scripts/gds/conductor-dispatch.js +0 -0
  55. package/scripts/gds/coreB-carve-proof.sh +0 -0
  56. package/scripts/gds/db-backup-nightly.sh +0 -0
  57. package/scripts/gds/db-backup-offsite.sh +0 -0
  58. package/scripts/gds/doctor.js +0 -0
  59. package/scripts/gds/grade-smoke.js +0 -0
  60. package/scripts/gds/hooks/pre-push.js +0 -0
  61. package/scripts/gds/install-git-hooks.js +0 -0
  62. package/scripts/gds/lint-migrations.js +102 -0
  63. package/scripts/gds/login.js +42 -10
  64. package/scripts/gds/main-audit.js +0 -0
  65. package/scripts/gds/mirror-redact.js +5 -0
  66. package/scripts/gds/oauth-secret.js +0 -0
  67. package/scripts/gds/onboarding-analyze.js +0 -0
  68. package/scripts/gds/onboarding-ship.js +0 -0
  69. package/scripts/gds/package-core.js +263 -4
  70. package/scripts/gds/paste-token.js +0 -0
  71. package/scripts/gds/portability-smoke-art.sh +0 -0
  72. package/scripts/gds/portability-smoke-memory.sh +0 -0
  73. package/scripts/gds/provision.js +270 -11
  74. package/scripts/gds/publish-manifest.js +15 -0
  75. package/scripts/gds/push-main.js +0 -0
  76. package/scripts/gds/render-ideas.js +0 -0
  77. package/scripts/gds/run-unit-tests.js +38 -14
  78. package/scripts/gds/security-baseline-check.sh +0 -0
  79. package/scripts/gds/session.js +0 -0
  80. package/scripts/gds/setup.js +41 -0
  81. package/scripts/gds/ship.js +126 -32
  82. package/scripts/gds/smoke-batch-claim.sh +0 -0
  83. package/scripts/gds/smoke-batch-release.sh +0 -0
  84. package/scripts/gds/smoke-box.sh +0 -0
  85. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  86. package/scripts/gds/smoke-dependencies.sh +0 -0
  87. package/scripts/gds/smoke-write.sh +0 -0
  88. package/scripts/gds/upgrade.js +147 -5
  89. package/scripts/hall-preview/server.js +18 -1
  90. package/scripts/migrate.sh +10 -0
  91. package/scripts/migrate_cost_ledger.sh +0 -0
  92. package/scripts/render-diagrams.sh +0 -0
  93. package/scripts/security/dep-audit.js +0 -0
  94. package/scripts/setup-builder.js +0 -0
  95. package/scripts/setup-builder.sh +0 -0
  96. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  97. package/src/bongos/auth.js +362 -5
  98. package/src/bongos/db.js +66 -0
  99. package/src/bongos/github-repos.js +93 -0
  100. package/src/bongos/routes/auth.js +93 -11
  101. package/src/bongos/routes/github.js +90 -0
  102. package/src/bongos/routes/instance.js +15 -3
  103. package/src/bongos/routes.js +2 -0
  104. package/src/branding.js +17 -2
  105. package/src/instance-config.js +16 -0
  106. package/src/module-api.js +48 -1
  107. package/tests/branding.mjs +29 -23
  108. package/tests/github_repos_helpers.mjs +91 -0
  109. package/tests/github_token_store.mjs +121 -0
  110. package/tests/idp_device_exchange.mjs +105 -0
  111. package/tests/idp_federation.mjs +100 -0
  112. package/tests/migrations_forward_safe.mjs +86 -0
  113. package/tests/module_api.mjs +1 -1
  114. package/tests/package_core.mjs +246 -1
  115. package/tests/provision.mjs +226 -0
  116. package/tests/pruned_lockfile_integrity.mjs +79 -0
  117. package/tests/ship_ci_deploy.mjs +60 -0
  118. package/tests/upgrade.mjs +254 -5
  119. package/.gitignore +0 -91
  120. package/scripts/hall-preview/fixtures/access-requests.json +0 -1
  121. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  122. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  123. package/scripts/hall-preview/fixtures/audit-log.json +0 -1
  124. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -1
  125. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  126. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  127. package/scripts/hall-preview/fixtures/builders__roster.json +0 -1
  128. package/scripts/hall-preview/fixtures/goals.json +0 -502
  129. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  130. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  131. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  132. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  133. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  134. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  135. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  136. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  137. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -1
  138. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  139. package/scripts/hall-preview/fixtures/instance.json +0 -124
  140. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  141. package/scripts/hall-preview/fixtures/me.json +0 -476
  142. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  143. package/scripts/hall-preview/fixtures/override-requests.json +0 -1
  144. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  145. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  146. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  147. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  148. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  149. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  150. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  151. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  152. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  153. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  154. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  155. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  156. package/scripts/hall-preview/fixtures/security__docs.json +0 -1
  157. package/scripts/hall-preview/fixtures/security__reports.json +0 -1
  158. package/scripts/hall-preview/fixtures/sessions__search.json +0 -1
  159. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  160. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  161. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  162. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  163. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -55,6 +55,20 @@ const { branding, userAgent } = require('../branding');
55
55
 
56
56
  const SCOPE = 'read:user';
57
57
 
58
+ // Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
59
+ // and the CLI Device Flow ever need. The onboarding wizard
60
+ // (public-landing/create.html), however, lists + creates the signed-in user's own
61
+ // GitHub repos (task 1002099) — which `read:user` cannot do — so it OPTS IN to a
62
+ // broader scope. We add `public_repo`, NOT the all-or-nothing `repo`: it still
63
+ // lists/creates PUBLIC repos (the norm for open-source Cloud Bongos instances)
64
+ // but shows a far gentler consent screen and shrinks the blast radius of the
65
+ // token we then persist (ADR 0143). Only the wizard passes { repos:true }; every
66
+ // other sign-in stays identity-only. Pure + exported so the exact scope string
67
+ // (a security-critical value) is unit-testable.
68
+ function scopeForWebFlow({ repos = false } = {}) {
69
+ return repos ? `${SCOPE} public_repo` : SCOPE;
70
+ }
71
+
58
72
  // In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
59
73
  // Device flow is ephemeral; restart of the Node process invalidates pending flows
60
74
  // (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
@@ -91,6 +105,116 @@ function authConfigured() {
91
105
  return !!loadGithubCreds();
92
106
  }
93
107
 
108
+ // -------------------------------------------------------------------------
109
+ // Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
110
+ // CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
111
+ // directly. Identity is federated; authority is NOT — the instance still runs its
112
+ // OWN admission gate + upsertBuilder + createSession, so the hub only answers
113
+ // "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
114
+ // unchanged fallback for self-hosted instances.
115
+ // -------------------------------------------------------------------------
116
+
117
+ // The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
118
+ // false so a branding hiccup degrades to the direct-GitHub path rather than
119
+ // breaking sign-in.
120
+ function idpEnabled() {
121
+ try { return branding().auth?.idp === true; } catch { return false; }
122
+ }
123
+
124
+ // Resolve the hub federation config. The switch is the flag above; the hub origin
125
+ // + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
126
+ // _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
127
+ // branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
128
+ // (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
129
+ // flag is on AND all three resolve → callers fall back to direct GitHub.
130
+ function loadIdpConfig() {
131
+ if (!idpEnabled()) return null;
132
+ const origin = ic.resolveEnv('HUB_ORIGIN');
133
+ const clientId = ic.resolveEnv('HUB_CLIENT_ID');
134
+ const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
135
+ if (origin && clientId && clientSecret) {
136
+ return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function idpConfigured() {
142
+ return !!loadIdpConfig();
143
+ }
144
+
145
+ // The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
146
+ // builder is the instance's configured firstAdmin, else builder. NEVER a rank.
147
+ function membershipRole({ githubLogin } = {}) {
148
+ const fa = firstAdminLogin();
149
+ if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
150
+ return 'builder';
151
+ }
152
+
153
+ // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
154
+ // github_id builds on project <client_id>, coarse role <role>". Runs on every
155
+ // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
156
+ // project still appears in the builder's My Projects with no hub browser session.
157
+ // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
158
+ // the onboarding stage tick). No-op on a self-hosted instance (idp unset).
159
+ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
160
+ const idp = loadIdpConfig();
161
+ if (!idp) return;
162
+ try {
163
+ await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
164
+ method: 'POST',
165
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
166
+ body: JSON.stringify({
167
+ client_id: idp.clientId,
168
+ client_secret: idp.clientSecret,
169
+ github_id: githubId,
170
+ github_login: githubLogin,
171
+ display_name: displayName ?? null,
172
+ avatar_url: avatarUrl ?? null,
173
+ role: membershipRole({ githubLogin }),
174
+ }),
175
+ });
176
+ } catch (err) {
177
+ console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
178
+ }
179
+ }
180
+
181
+ // Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
182
+ // builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
183
+ // for the internal scouting directory. Fires on every federated sign-in ("on key
184
+ // events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
185
+ // not federated — never fails a sign-in.
186
+ async function reportActivityRollup({ builderId, githubId }) {
187
+ const idp = loadIdpConfig();
188
+ if (!idp) return;
189
+ try {
190
+ // Snapshot the builder's talent signals directly from the DB (auth.js is a
191
+ // kernel file — it uses pool, never a non-kernel domain module like metrics.js).
192
+ const b = await pool.query(
193
+ 'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
194
+ [builderId],
195
+ );
196
+ if (!b.rows[0]) return;
197
+ const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
198
+ const row = b.rows[0];
199
+ await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
200
+ method: 'POST',
201
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
202
+ body: JSON.stringify({
203
+ client_id: idp.clientId,
204
+ client_secret: idp.clientSecret,
205
+ github_id: githubId,
206
+ credits: Number(row.total_credits) || 0,
207
+ tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
208
+ karma: Number(row.karma) || 0,
209
+ rank: row.rank || null,
210
+ disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
211
+ }),
212
+ });
213
+ } catch (err) {
214
+ console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
215
+ }
216
+ }
217
+
94
218
  // Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
95
219
  // sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
96
220
  // "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
@@ -201,7 +325,11 @@ async function exchangeCodeForToken(code, redirectUri) {
201
325
  });
202
326
  const data = await res.json();
203
327
  if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
204
- return data.access_token;
328
+ // Return the GRANTED scope alongside the token. GitHub echoes back the scopes
329
+ // the user actually consented to (space/comma-separated) — the caller checks it
330
+ // before persisting a repo-capable token, so a user who declined the broader
331
+ // consent never has one stored (task 1002099 / ADR 0143).
332
+ return { accessToken: data.access_token, scope: data.scope || '' };
205
333
  }
206
334
 
207
335
  // -------------------------------------------------------------------------
@@ -360,19 +488,21 @@ async function pollDeviceFlow(deviceCode, userAgent) {
360
488
  // Web Flow (browser)
361
489
  // -------------------------------------------------------------------------
362
490
 
363
- function buildAuthorizeUrl(state, redirectUri) {
491
+ function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
364
492
  const creds = loadGithubCreds();
365
493
  if (!creds) throw new Error('GitHub OAuth credentials not configured');
366
494
  const u = new URL('https://github.com/login/oauth/authorize');
367
495
  u.searchParams.set('client_id', creds.client_id);
368
496
  u.searchParams.set('redirect_uri', redirectUri);
369
- u.searchParams.set('scope', SCOPE);
497
+ // `repos:true` (the onboarding wizard only) requests the broader repo scope; every
498
+ // other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
499
+ u.searchParams.set('scope', scopeForWebFlow({ repos }));
370
500
  u.searchParams.set('state', state);
371
501
  return u.toString();
372
502
  }
373
503
 
374
- async function completeWebFlow({ code, redirectUri, userAgent }) {
375
- const accessToken = await exchangeCodeForToken(code, redirectUri);
504
+ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
505
+ const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
376
506
  const ghUser = await fetchGithubUser(accessToken);
377
507
  // Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
378
508
  // with a GitHub account. A first-time visitor must have been approved by an
@@ -423,9 +553,225 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
423
553
  });
424
554
  // V3.R75 (#294): tick the onboarding state machine. Best-effort.
425
555
  await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
556
+ // Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
557
+ // repo access, hand the GitHub access token + granted scope back so the web
558
+ // callback can persist it (encrypted, short-TTL) for the repo picker/creator.
559
+ // auth.js stays kernel-clean — it never writes the token to the DB itself; the
560
+ // token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
561
+ // even returns it.
562
+ const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
563
+ return { token, builder, expiresAt, ...captured };
564
+ }
565
+
566
+ // -------------------------------------------------------------------------
567
+ // Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
568
+ // -------------------------------------------------------------------------
569
+
570
+ // Build the hub's authorize URL. The instance redirects the browser here (instead
571
+ // of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
572
+ // instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
573
+ // by provisioning); state is the same CSRF nonce the callback validates.
574
+ // PURE — construct the hub authorize URL (testable without branding/env). Trailing
575
+ // slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
576
+ function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
577
+ const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
578
+ u.searchParams.set('client_id', clientId);
579
+ u.searchParams.set('redirect_uri', redirectUri);
580
+ u.searchParams.set('state', state);
581
+ return u.toString();
582
+ }
583
+
584
+ function buildIdpAuthorizeUrl(state, redirectUri) {
585
+ const idp = loadIdpConfig();
586
+ if (!idp) throw new Error('platform IdP not configured');
587
+ return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
588
+ }
589
+
590
+ // Server-to-server: redeem the hub authorization code for identity. Authenticates
591
+ // with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
592
+ // (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
593
+ async function exchangeIdpCode({ code, redirectUri, idp }) {
594
+ const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
595
+ method: 'POST',
596
+ headers: {
597
+ 'Content-Type': 'application/json',
598
+ Accept: 'application/json',
599
+ 'User-Agent': userAgent(),
600
+ },
601
+ body: JSON.stringify({
602
+ client_id: idp.clientId,
603
+ client_secret: idp.clientSecret,
604
+ code,
605
+ redirect_uri: redirectUri,
606
+ }),
607
+ });
608
+ if (!res.ok) {
609
+ const text = await res.text().catch(() => '');
610
+ throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
611
+ }
612
+ const data = await res.json();
613
+ if (!data || data.github_id == null) {
614
+ throw new Error('hub /sso/token returned no identity');
615
+ }
616
+ return data;
617
+ }
618
+
619
+ // Complete the instance web sign-in via the hub. Sources identity from the hub's
620
+ // /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
621
+ // maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
622
+ // identity SOURCE differs (the hub, not github.com). The shared PURE gate
623
+ // (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
624
+ // path (SR-19). Returns the same { token, builder, expiresAt } shape.
625
+ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
626
+ const idp = loadIdpConfig();
627
+ if (!idp) throw new Error('platform IdP not configured');
628
+ const identity = await exchangeIdpCode({ code, redirectUri, idp });
629
+ const ghId = identity.github_id;
630
+ const ghLogin = identity.github_login;
631
+ const existing = await getBuilderByGithubId(ghId);
632
+ if (!existing && !openEnrollmentEnabled()) {
633
+ const approved = await hasInvitedAccessRequest(ghLogin);
634
+ if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
635
+ const err = new Error('not_approved');
636
+ err.code = 'NOT_APPROVED';
637
+ throw err;
638
+ }
639
+ }
640
+ let builder = await upsertBuilder({
641
+ githubId: ghId,
642
+ githubLogin: ghLogin,
643
+ displayName: identity.display_name || ghLogin,
644
+ avatarUrl: identity.avatar_url,
645
+ });
646
+ builder = await maybeSeatFirstAdmin(builder);
647
+ const token = newSessionToken();
648
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
649
+ await createSession({
650
+ builderId: builder.id,
651
+ token,
652
+ source: 'web',
653
+ userAgent: ua ?? null,
654
+ expiresAt,
655
+ });
656
+ await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
657
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated web sign-in.
658
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url });
659
+ reportActivityRollup({ builderId: builder.id, githubId: ghId });
426
660
  return { token, builder, expiresAt };
427
661
  }
428
662
 
663
+ // -------------------------------------------------------------------------
664
+ // Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
665
+ // assertion OFFLINE and mints its own cli session. The CLI runs the device flow
666
+ // against the HUB (GitHub device codes can't cross apps), the hub signs an
667
+ // audience-scoped Ed25519 assertion, and the instance verifies it here with the
668
+ // hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
669
+ // -------------------------------------------------------------------------
670
+
671
+ const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
672
+
673
+ // The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
674
+ // instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
675
+ function loadHubPublicKey() {
676
+ const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
677
+ if (!b64) return null;
678
+ try {
679
+ return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
680
+ } catch (err) {
681
+ console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
682
+ return null;
683
+ }
684
+ }
685
+
686
+ // Verify a hub-signed assertion (the compact-JWT format minted by the hub's
687
+ // platform-identity/hub-keys.js — kept in lockstep across the core<->module
688
+ // boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
689
+ // public key), then the issuer, audience (must equal THIS instance's client_id),
690
+ // and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
691
+ // regardless of the header's `alg` field, so there is no algorithm-confusion attack.
692
+ // Returns the validated payload, or throws. Pure over its inputs (testable).
693
+ function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
694
+ if (!publicKey) throw new Error('hub public key not configured');
695
+ const parts = String(token || '').split('.');
696
+ if (parts.length !== 3) throw new Error('malformed assertion');
697
+ const [h, p, s] = parts;
698
+ let header;
699
+ try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
700
+ catch { throw new Error('malformed assertion header'); }
701
+ if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
702
+ // Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
703
+ const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
704
+ if (!ok) throw new Error('assertion signature invalid');
705
+ let payload;
706
+ try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
707
+ catch { throw new Error('malformed assertion payload'); }
708
+ if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
709
+ if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
710
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
711
+ if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
712
+ return payload;
713
+ }
714
+
715
+ // Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
716
+ // aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
717
+ // assertion's own expiry, so a captured assertion can't be replayed into multiple
718
+ // sessions even inside its ~120s life. A restart clears it — acceptable given the
719
+ // short window and that the aud binding is the primary control.
720
+ const _seenAssertionJti = new Map(); // jti -> expiry ms
721
+ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
722
+ for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
723
+ if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
724
+ if (_seenAssertionJti.has(jti)) return true;
725
+ _seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
726
+ return false;
727
+ }
728
+
729
+ // Complete a CLI federation exchange: verify the hub assertion, then run the SAME
730
+ // admission gate + upsertBuilder + createSession(source='cli') as the direct device
731
+ // flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
732
+ async function completeDeviceExchange({ assertion, userAgent: ua }) {
733
+ const idp = loadIdpConfig();
734
+ if (!idp) throw new Error('platform IdP not configured');
735
+ const publicKey = loadHubPublicKey();
736
+ if (!publicKey) throw new Error('hub public key not configured');
737
+ const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
738
+ // Reject a replayed assertion (same jti) within its window.
739
+ if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
740
+ throw new Error('assertion already used');
741
+ }
742
+ const ghId = payload.sub;
743
+ const ghLogin = payload.github_login;
744
+ const existing = await getBuilderByGithubId(ghId);
745
+ if (!existing && !openEnrollmentEnabled()) {
746
+ const approved = await hasInvitedAccessRequest(ghLogin);
747
+ if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
748
+ const err = new Error('not_approved');
749
+ err.code = 'NOT_APPROVED';
750
+ throw err;
751
+ }
752
+ }
753
+ let builder = await upsertBuilder({
754
+ githubId: ghId,
755
+ githubLogin: ghLogin,
756
+ displayName: payload.display_name || ghLogin,
757
+ avatarUrl: payload.avatar_url,
758
+ });
759
+ builder = await maybeSeatFirstAdmin(builder);
760
+ const token = newSessionToken();
761
+ await createSession({
762
+ builderId: builder.id,
763
+ token,
764
+ source: 'cli',
765
+ userAgent: ua ?? null,
766
+ expiresAt: null,
767
+ });
768
+ await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
769
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated CLI sign-in.
770
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url });
771
+ reportActivityRollup({ builderId: builder.id, githubId: ghId });
772
+ return { token, builder };
773
+ }
774
+
429
775
  // -------------------------------------------------------------------------
430
776
  // Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
431
777
  // -------------------------------------------------------------------------
@@ -969,6 +1315,9 @@ function requireDiscordBot(req, res, next) {
969
1315
 
970
1316
  module.exports = {
971
1317
  authConfigured,
1318
+ idpEnabled,
1319
+ loadIdpConfig,
1320
+ idpConfigured,
972
1321
  openEnrollmentEnabled,
973
1322
  firstTimeAdmissionBlocked,
974
1323
  firstAdminLogin,
@@ -976,7 +1325,15 @@ module.exports = {
976
1325
  startDeviceFlow,
977
1326
  pollDeviceFlow,
978
1327
  buildAuthorizeUrl,
1328
+ scopeForWebFlow,
1329
+ buildIdpAuthorizeUrl,
1330
+ idpAuthorizeUrl,
979
1331
  completeWebFlow,
1332
+ completeIdpFlow,
1333
+ loadHubPublicKey,
1334
+ verifyHubAssertion,
1335
+ completeDeviceExchange,
1336
+ membershipRole,
980
1337
  extractTokens,
981
1338
  resolveSession,
982
1339
  allowBoxScope,
package/src/bongos/db.js CHANGED
@@ -25,6 +25,7 @@
25
25
  // grade-count SQL runs on both sides of the kernel↔module line — keep in sync.
26
26
 
27
27
  const { pool } = require('./pool');
28
+ const secretBox = require('./secret-box');
28
29
  // Module seam (ADR 0083 / BV1.R43). The rank/status-change paths EMIT
29
30
  // `builder.box-reconcile-needed` AFTER COMMIT (best-effort) so the dev-box module
30
31
  // can reconcile a droplet a demotion/graduation implies — never a direct require
@@ -205,6 +206,68 @@ async function updateBuilderDisciplines(builderId, disciplines) {
205
206
  // { enc: null, ... } when no key is set.
206
207
 
207
208
 
209
+ // ---- GitHub OAuth repo token, encrypted at rest (core_192 / task 1002099) ----
210
+ //
211
+ // The onboarding wizard (public-landing/create.html) opts into `public_repo` at
212
+ // sign-in so it can list + create the user's repos. That user-to-server GitHub
213
+ // token is held here BRIEFLY: encrypted with secret-box (master key in server env
214
+ // only — the plaintext never touches the DB) and given a short TTL (one wizard
215
+ // sitting), read back by the repo picker/creator, then deleted on use. It is the
216
+ // auth system's OWN handshake token (distinct from a feature's third-party key
217
+ // like the art module's Gemini key, ADR 0102), so it lives in core auth. secret-box
218
+ // is the gate: with no master key configured we NEVER store a plaintext token —
219
+ // capture no-ops and the wizard degrades to manual repo entry (ADR 0143).
220
+ const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — one wizard sitting.
221
+
222
+ // Store (or replace) the builder's GitHub repo token. `token` is the raw
223
+ // user-to-server access token; it is encrypted here so the plaintext never touches
224
+ // the DB. No-ops when secret-box is unconfigured (returns { stored:false }).
225
+ async function upsertGithubToken({ builderId, token, scope = null, ttlMs = GITHUB_TOKEN_TTL_MS, p = pool }) {
226
+ if (!secretBox.isConfigured()) return { stored: false };
227
+ const enc = secretBox.encrypt(token);
228
+ const expiresAt = new Date(Date.now() + ttlMs);
229
+ await p.query(
230
+ `INSERT INTO github_oauth_tokens (builder_id, token_enc, scope, created_at, expires_at)
231
+ VALUES ($1, $2, $3, now(), $4)
232
+ ON CONFLICT (builder_id) DO UPDATE
233
+ SET token_enc = EXCLUDED.token_enc, scope = EXCLUDED.scope,
234
+ created_at = now(), expires_at = EXCLUDED.expires_at`,
235
+ [builderId, enc, scope, expiresAt],
236
+ );
237
+ return { stored: true, expires_at: expiresAt };
238
+ }
239
+
240
+ // Read + decrypt the builder's GitHub token, or null when there is none, it has
241
+ // expired, or secret-box is unconfigured. Lazily deletes an expired row so the
242
+ // table self-cleans without a cron (mirrors the deviceFlows reaping in auth.js).
243
+ // The 503-vs-403 distinction the endpoint needs is made by the endpoint checking
244
+ // secretBox.isConfigured() itself before calling this.
245
+ async function getGithubToken({ builderId, p = pool }) {
246
+ if (!secretBox.isConfigured()) return null;
247
+ const { rows } = await p.query(
248
+ `SELECT token_enc, scope, expires_at FROM github_oauth_tokens WHERE builder_id = $1`,
249
+ [builderId],
250
+ );
251
+ const row = rows[0];
252
+ if (!row) return null;
253
+ if (new Date(row.expires_at).getTime() <= Date.now()) {
254
+ await p.query(`DELETE FROM github_oauth_tokens WHERE builder_id = $1`, [builderId]).catch(() => {});
255
+ return null;
256
+ }
257
+ let token;
258
+ try { token = secretBox.decrypt(row.token_enc); }
259
+ catch { return null; } // a blob that won't decrypt (e.g. rotated master key) reads as "none".
260
+ return { token, scope: row.scope, expires_at: row.expires_at };
261
+ }
262
+
263
+ // Delete the builder's token (called after the wizard's terminal enqueue, and on
264
+ // expiry). Best-effort; safe to call when nothing is stored.
265
+ async function deleteGithubToken({ builderId, p = pool }) {
266
+ await p.query(`DELETE FROM github_oauth_tokens WHERE builder_id = $1`, [builderId]);
267
+ return { deleted: true };
268
+ }
269
+
270
+
208
271
  // leaderboard() carved into modules/economy/credits.js (BV1.R77 / ADR 0093 §1-2).
209
272
  // Core read routes resolve it via the `reward` kernel port.
210
273
 
@@ -908,4 +971,7 @@ module.exports = {
908
971
  upsertSecurityDoc,
909
972
  getSecurityDoc,
910
973
  listSecurityDocs,
974
+ upsertGithubToken,
975
+ getGithubToken,
976
+ deleteGithubToken,
911
977
  };
@@ -0,0 +1,93 @@
1
+ // GitHub repo list/create on behalf of the signed-in user — the server half of the
2
+ // onboarding wizard's repo picker (adopt) + auto-create (fresh). Task 1002099 / ADR 0143.
3
+ //
4
+ // Raw `fetch` against GitHub's REST API (the house style — see auth.js
5
+ // fetchGithubUser; no octokit dependency). The user-to-server token these take is
6
+ // the `public_repo`-scoped token the web callback captured and stored (db.js
7
+ // github_oauth_tokens); callers pass the decrypted token in, this module never
8
+ // touches storage. `fetchImpl` is injectable so tests can drive it with a mock and
9
+ // never hit the network.
10
+
11
+ const { userAgent } = require('../branding');
12
+
13
+ const GH_API = 'https://api.github.com';
14
+ const PER_PAGE = 100;
15
+ // Up to 300 most-recently-updated OWNED repos — plenty to back a picker for the vast
16
+ // majority of accounts, and a hard bound so a power user can't make us page forever
17
+ // (v1 cap; the picker filters this set client-and-server-side by substring).
18
+ const MAX_PAGES = 3;
19
+
20
+ class GithubApiError extends Error {
21
+ constructor(message, { status, code } = {}) {
22
+ super(message);
23
+ this.name = 'GithubApiError';
24
+ this.status = status;
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ function ghHeaders(token, extra = {}) {
30
+ return {
31
+ Authorization: `Bearer ${token}`,
32
+ 'User-Agent': userAgent(),
33
+ Accept: 'application/vnd.github+json',
34
+ ...extra,
35
+ };
36
+ }
37
+
38
+ // List the signed-in user's OWN repos (affiliation=owner so `full_name` is always
39
+ // `login/repo` and the org-OAuth-policy complexity is out of scope for v1),
40
+ // most-recently-updated first, optionally filtered by a case-insensitive substring
41
+ // `q` on the full name. Returns [{ full_name, name, owner, private }].
42
+ async function listUserRepos(token, { q = '', fetchImpl = fetch } = {}) {
43
+ const out = [];
44
+ for (let page = 1; page <= MAX_PAGES; page++) {
45
+ const url = `${GH_API}/user/repos?per_page=${PER_PAGE}&page=${page}&sort=updated&affiliation=owner`;
46
+ const res = await fetchImpl(url, { headers: ghHeaders(token) });
47
+ if (res.status === 401) {
48
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
49
+ }
50
+ if (!res.ok) {
51
+ throw new GithubApiError(`GitHub /user/repos failed (${res.status})`, { status: res.status, code: 'github_error' });
52
+ }
53
+ const rows = await res.json();
54
+ if (!Array.isArray(rows) || rows.length === 0) break;
55
+ for (const r of rows) {
56
+ out.push({
57
+ full_name: r.full_name,
58
+ name: r.name,
59
+ owner: r.owner && r.owner.login,
60
+ private: !!r.private,
61
+ });
62
+ }
63
+ if (rows.length < PER_PAGE) break;
64
+ }
65
+ const needle = String(q || '').trim().toLowerCase();
66
+ return needle ? out.filter((r) => String(r.full_name).toLowerCase().includes(needle)) : out;
67
+ }
68
+
69
+ // Create an EMPTY repo in the signed-in user's personal account. auto_init:false =
70
+ // no README/first commit (exactly the "leave it completely empty" the manual step
71
+ // demanded); private:false because we hold only `public_repo`. Returns
72
+ // { full_name, html_url }. A 422 (name already exists / invalid) becomes a typed
73
+ // `name_taken` error the route maps to a friendly 409 rather than an opaque 500.
74
+ async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
75
+ const res = await fetchImpl(`${GH_API}/user/repos`, {
76
+ method: 'POST',
77
+ headers: ghHeaders(token, { 'Content-Type': 'application/json' }),
78
+ body: JSON.stringify({ name, private: false, auto_init: false }),
79
+ });
80
+ if (res.status === 201) {
81
+ const r = await res.json();
82
+ return { full_name: r.full_name, html_url: r.html_url };
83
+ }
84
+ if (res.status === 401) {
85
+ throw new GithubApiError('GitHub rejected the stored token', { status: 401, code: 'github_unauthorized' });
86
+ }
87
+ if (res.status === 422) {
88
+ throw new GithubApiError('A repository with that name already exists on your account', { status: 422, code: 'name_taken' });
89
+ }
90
+ throw new GithubApiError(`GitHub create-repo failed (${res.status})`, { status: res.status, code: 'github_error' });
91
+ }
92
+
93
+ module.exports = { listUserRepos, createUserRepo, GithubApiError, MAX_PAGES };