@bongos/core 1.24.5 → 1.24.6

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 (44) hide show
  1. package/.bongos-core.json +70 -45
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +7 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
  8. package/docs/api/openapi.json +110 -5
  9. package/docs/api-reference.md +6 -3
  10. package/docs/copy-inventory.md +36 -32
  11. package/docs/copy-registry.json +69 -33
  12. package/docs/module-api-changelog.md +2 -0
  13. package/docs/page-inventory.json +2 -1
  14. package/docs/page-readings.json +63 -55
  15. package/migrations/core_286_credit_log_no_spend.sql +128 -0
  16. package/modules/copy-desk/auto-close.js +5 -3
  17. package/modules/copy-desk/page-status.js +7 -4
  18. package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
  19. package/modules/lifecycle/routes/claims.js +7 -1
  20. package/modules/platform-identity/account-logins.js +131 -0
  21. package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
  22. package/modules/platform-identity/routes/profile.js +29 -1
  23. package/modules/platform-identity/routes/sso.js +25 -3
  24. package/modules/public-landing/public/account.html +114 -0
  25. package/modules/public-landing/public/account.states.json +1 -0
  26. package/modules/ui-design/kit/serve.js +24 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +18 -0
  30. package/scripts/bongos/run-unit-tests.js +4 -0
  31. package/src/bongos/auth-github.js +24 -7
  32. package/src/bongos/routes/auth-google.js +71 -5
  33. package/src/bongos/routes/auth-link.js +132 -0
  34. package/src/bongos/routes.js +2 -0
  35. package/src/bongos/sign-in-pages.js +20 -1
  36. package/src/module-api.js +1 -1
  37. package/tests/account_linking.mjs +289 -0
  38. package/tests/account_logins_db.mjs +56 -0
  39. package/tests/claims_batch_routes.mjs +79 -12
  40. package/tests/copy_desk_page_release.mjs +20 -0
  41. package/tests/copy_stuck_page_tweaks.mjs +2 -2
  42. package/tests/credit_log_no_spend_db.mjs +160 -0
  43. package/tests/effective_visibility_predicate.mjs +8 -0
  44. package/tests/hub_account_page.mjs +91 -5
@@ -580,6 +580,27 @@ function myGuilds(res) {
580
580
  }));
581
581
  return json(res, 200, { guilds: mine, count: mine.length });
582
582
  }
583
+ // GET/DELETE /me/sign-in-methods (the account page's ways to sign in, task 1004995).
584
+ // The fixture builder is GitHub-born with Google linked, held for the run so a
585
+ // Remove reads back. Each way in carries the route's own unlink_refused, by
586
+ // account-logins.js unlinkRefusal's rule, and DELETE refuses as the route does.
587
+ let _ways = null;
588
+ function waysHeld() { return _ways || (_ways = [{ provider: 'github', email: null }, { provider: 'google', email: 'octo@example.com' }]); }
589
+ function wayRefusal(provider, count) { return provider === 'github' ? 'born_with' : count <= 1 ? 'last_way_in' : null; }
590
+ function signInMethods(res) {
591
+ const held = waysHeld();
592
+ return json(res, 200, { methods: held.map((m) => ({ ...m, unlink_refused: wayRefusal(m.provider, held.length) })), providers: ['github', 'google'] });
593
+ }
594
+ function signInMethodDelete(res, provider) {
595
+ const held = waysHeld();
596
+ if (!['github', 'google'].includes(provider)) return refuse(res, 404, 'unknown_provider', 'unknown_provider');
597
+ if (!held.some((m) => m.provider === provider)) return refuse(res, 404, 'not_linked', 'that way in is not linked to your account');
598
+ const why = wayRefusal(provider, held.length);
599
+ if (why === 'born_with') return refuse(res, 409, why, 'your account was made with GitHub, so GitHub stays linked');
600
+ if (why) return refuse(res, 409, why, 'this is your only way in, so it cannot be removed — link another first');
601
+ _ways = held.filter((m) => m.provider !== provider);
602
+ return json(res, 200, { ok: true });
603
+ }
583
604
  function myGuildRequests(res) {
584
605
  const q = heldFixture('me__guild-requests');
585
606
  const owner = ownedRequests();
@@ -790,6 +811,9 @@ const server = http.createServer((req, res) => {
790
811
  if (guild && guild[2] === '/membership' && req.method === 'PATCH') return AUTH ? readBody(req, (body) => guildMembershipPatch(res, guild[1], body)) : json(res, 401, UNAUTH);
791
812
  if (guild && guild[2] === '/engagements' && req.method === 'POST') return AUTH ? readBody(req, (body) => guildEngagementPost(res, guild[1], body)) : json(res, 401, UNAUTH);
792
813
  if (rest === '/me/guilds' && req.method === 'GET') return AUTH ? myGuilds(res) : json(res, 401, UNAUTH);
814
+ if (rest === '/me/sign-in-methods' && req.method === 'GET') return AUTH ? signInMethods(res) : json(res, 401, UNAUTH);
815
+ const way = rest.match(/^\/me\/sign-in-methods\/([^/]+)$/);
816
+ if (way && req.method === 'DELETE') return AUTH ? signInMethodDelete(res, way[1]) : json(res, 401, UNAUTH);
793
817
  if (rest === '/me/guild-requests' && req.method === 'GET') return AUTH ? myGuildRequests(res) : json(res, 401, UNAUTH);
794
818
  if (rest === '/community/leaderboard' && req.method === 'GET') return communityBoard(res, u, origin);
795
819
  if (rest === '/community/search' && req.method === 'GET') return communitySearch(res, u, origin);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.5",
3
+ "version": "1.24.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.5",
9
+ "version": "1.24.6",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.5",
3
+ "version": "1.24.6",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10853,5 +10853,23 @@
10853
10853
  "id": "1005006",
10854
10854
  "text": "The Treasury screen now counts builder rewards in credits instead of dollars, and no longer says one credit is a dollar."
10855
10855
  }
10856
+ ],
10857
+ "1.24.6": [
10858
+ {
10859
+ "id": "1004976",
10860
+ "text": "A page tweak draft nobody has touched for a day now closes by itself, as the owner asked, and the words written so far stay saved on the task."
10861
+ },
10862
+ {
10863
+ "id": "1005005",
10864
+ "text": "Claiming several tasks at once now follows the same rule as claiming one: a work session that already has a task in progress in a folder cannot pile more tasks into that same folder."
10865
+ },
10866
+ {
10867
+ "id": "1004988",
10868
+ "text": "The database itself now refuses any credit entry that spends, redeems or transfers credits; only an undo of an earlier entry may take credits away, and existing records are untouched."
10869
+ },
10870
+ {
10871
+ "id": "1004995",
10872
+ "text": "On the account page, people can now link GitHub and Google to one account, both ways, so either one opens the same account with the same credits, ranks and history. They can remove one too, but never their last way in. Two exi"
10873
+ }
10856
10874
  ]
10857
10875
  }
@@ -259,6 +259,10 @@ const INTEGRATION = new Set([
259
259
  // task 1004914 (ADR 0385 D8): the payout gate tables from the real migration —
260
260
  // append-only, no silent hold, who may appeal, decide and co-sign, the gate end to end.
261
261
  'payout_gates_db',
262
+ // task 1004988 (ADR 0385 D2): credit_log from the real migration — Postgres
263
+ // refuses a spend, redemption or transfer row; only a reversal of an existing
264
+ // row may be negative, and core_194's clawback still books.
265
+ 'credit_log_no_spend_db',
262
266
  // task 1004408: migration platform_identity_028 deletes the old hub invite's
263
267
  // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
264
268
  // engine runs the DO block, the numeric version compare and the one-shot guard.
@@ -189,7 +189,8 @@ async function pollDeviceFlow(deviceCode, userAgent) {
189
189
  // admitted only if open enrollment is on OR an Archon marked their
190
190
  // access_request 'invited'. An EXISTING builder ALWAYS passes, so an approved
191
191
  // builder can never be locked out of CLI enrollment. See ADR 0050.
192
- const existing = await getBuilderByGithubId(ghUser.id);
192
+ const personKey = await githubPersonKey(ghUser);
193
+ const existing = await getBuilderByGithubId(personKey);
193
194
  // Only consult the access_request table for a genuine first-timer (and only
194
195
  // when open enrollment is off) — an existing builder short-circuits to
195
196
  // ALWAYS-pass, matching completeWebFlow's `if (!existing)` lookup.
@@ -202,7 +203,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
202
203
  }
203
204
  }
204
205
  let builder = await upsertBuilder({
205
- githubId: ghUser.id,
206
+ githubId: personKey,
206
207
  githubLogin: ghUser.login,
207
208
  displayName: ghUser.name || ghUser.login,
208
209
  avatarUrl: ghUser.avatar_url,
@@ -243,12 +244,26 @@ async function pollDeviceFlow(deviceCode, userAgent) {
243
244
  // hub provides the function, so every other instance skips it. The identity is
244
245
  // GitHub's answer for the exchanged code, never a request field. Best-effort: a
245
246
  // failed stamp must not cost the sign-in, and the next sign-in writes it.
246
- async function recordHubSignin(ghUser) {
247
+ // Who a GitHub sign-in reaches (task 1004995, the ADR 0393 carry-over). On the
248
+ // hub a GitHub account may be linked to someone who joined with Google, whose
249
+ // builder row is keyed on their minted person key, not the GitHub id — so the
250
+ // hub is asked first, or the sign-in would make that person a second account.
251
+ // Off the hub there is no port and the GitHub id is the key, as it always was.
252
+ // Not best-effort: if the hub cannot answer, the sign-in fails rather than guess.
253
+ async function githubPersonKey(ghUser) {
254
+ const hub = seams.resolveOptional('platform-identity.hub');
255
+ if (!hub || typeof hub.personKeyForGithub !== 'function') return String(ghUser.id);
256
+ return (await hub.personKeyForGithub(String(ghUser.id))) || String(ghUser.id);
257
+ }
258
+
259
+ async function recordHubSignin(ghUser, personKey) {
247
260
  try {
248
261
  const hub = seams.resolveOptional('platform-identity.hub');
249
262
  if (!hub || typeof hub.recordHubSignin !== 'function') return;
250
263
  await hub.recordHubSignin({
251
- githubId: String(ghUser.id),
264
+ githubId: String(personKey || ghUser.id),
265
+ githubUserId: String(ghUser.id),
266
+ email: typeof ghUser.email === 'string' ? ghUser.email : null,
252
267
  githubLogin: ghUser.login,
253
268
  displayName: ghUser.name || ghUser.login,
254
269
  avatarUrl: ghUser.avatar_url,
@@ -293,7 +308,8 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
293
308
  // so they cannot drift. An owner who wants fully-open self-enrollment sets
294
309
  // OTB_OPEN_XENOS_ENROLLMENT truthy (no code change); default = invite required.
295
310
  // See ADR 0050.
296
- const existing = await getBuilderByGithubId(ghUser.id);
311
+ const personKey = await githubPersonKey(ghUser);
312
+ const existing = await getBuilderByGithubId(personKey);
297
313
  if (!existing && !openEnrollmentEnabled()) {
298
314
  const approved = await hasInvitedAccessRequest(ghUser.login);
299
315
  // Pass `login` so the configured-firstAdmin bypass (firstTimeAdmissionBlocked
@@ -315,7 +331,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
315
331
  }
316
332
  }
317
333
  let builder = await upsertBuilder({
318
- githubId: ghUser.id,
334
+ githubId: personKey,
319
335
  githubLogin: ghUser.login,
320
336
  displayName: ghUser.name || ghUser.login,
321
337
  avatarUrl: ghUser.avatar_url,
@@ -345,7 +361,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
345
361
  });
346
362
  // V3.R75 (#294): tick the onboarding state machine. Best-effort.
347
363
  await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
348
- await recordHubSignin(ghUser);
364
+ await recordHubSignin(ghUser, personKey);
349
365
  // Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
350
366
  // repo access, hand the GitHub access token + granted scope back so the web
351
367
  // callback can persist it (encrypted, short-TTL) for the repo picker/creator.
@@ -372,6 +388,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
372
388
  module.exports = {
373
389
  buildAuthorizeUrl,
374
390
  completeWebFlow,
391
+ exchangeCodeForToken,
375
392
  fetchGithubUser,
376
393
  newSessionToken,
377
394
  pollDeviceFlow,
@@ -26,8 +26,9 @@ const { pool } = require('../pool');
26
26
  const { parseCookie, validateOrRespond } = require('./_helpers');
27
27
  const { googleConfigured, buildGoogleAuthorizeUrl, exchangeGoogleCode, completeGoogleFlow } = require('../auth-google');
28
28
  const { pendingFlow, decideDeviceFlow, normalizeUserCode } = require('../device-approval');
29
- const { renderDeviceApprovalPage } = require('../sign-in-pages');
29
+ const { renderDeviceApprovalPage, renderExistingAccountHint } = require('../sign-in-pages');
30
30
  const authRoutes = require('./auth');
31
+ const authLink = require('./auth-link');
31
32
  const log = require('../logger').logger.child({ src: 'auth-google' });
32
33
 
33
34
  // state.nonce for one Google sign-in, host-only, alive as long as GitHub's handshake.
@@ -77,11 +78,52 @@ function sameSitePost(req) {
77
78
  try { return new URL(origin).hostname.toLowerCase() === authRoutes.reqHost(req); } catch { return false; }
78
79
  }
79
80
 
81
+ // Is this a first Google sign-in whose verified address an account that signs in
82
+ // with GitHub also shows? Then the person is offered that account before a second
83
+ // one is made (task 1004995). A suggestion only: an unverified address never
84
+ // counts, and a failed read shows no hint rather than blocking the sign-in.
85
+ async function suggestsExistingGithub(identity) {
86
+ if (!identity || !identity.emailVerified || !identity.email) return false;
87
+ const hub = seams.resolveOptional('platform-identity.hub');
88
+ if (!hub || typeof hub.googleSignInHint !== 'function') return false;
89
+ try {
90
+ const hint = await hub.googleSignInHint({ subject: identity.subject, email: identity.email });
91
+ return Array.isArray(hint) && hint.includes('github');
92
+ } catch (err) {
93
+ log.error({ err: err && err.message }, '[auth] google sign-in hint');
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // The second half of Link Google (task 1004995): the Google account the person
99
+ // just picked becomes another way into the account they are signed in to, or the
100
+ // account page says why not. Never a sign-in, and never a merge.
101
+ async function finishGoogleLink(req, res, { expectedState, nonce, state, code, error }) {
102
+ if (!authLink.hubLinker()) return sendNotOffered(res);
103
+ if (error || !code || !state || !expectedState || !nonce || state !== expectedState) {
104
+ return res.redirect(authLink.linkLanding('google', { refused: error === 'access_denied' ? 'cancelled' : 'expired' }));
105
+ }
106
+ let session = null;
107
+ try { session = await auth.resolveSession(req); } catch (err) { log.error({ err: err && err.message }, '[auth] google link session'); }
108
+ if (!session || session.status === 'inactive') return res.redirect('/account');
109
+ try {
110
+ const identity = await exchangeGoogleCode({ code, redirectUri: googleRedirectUri(), nonce });
111
+ const out = await authLink.linkToSession({
112
+ session, provider: 'google', subject: identity.subject,
113
+ email: identity.emailVerified ? identity.email : null,
114
+ });
115
+ return res.redirect(authLink.linkLanding('google', out));
116
+ } catch (err) {
117
+ log.error({ err: err && err.message }, '[auth] google link');
118
+ return res.redirect(authLink.linkLanding('google', { refused: 'failed' }));
119
+ }
120
+ }
121
+
80
122
  module.exports = function buildAuthGoogleRouter() {
81
123
  const router = express.Router();
82
124
 
83
125
  // rank: public — entry into the Google OAuth handshake; identity establishment only.
84
- router.get('/auth/google/start', (req, res) => {
126
+ router.get('/auth/google/start', async (req, res) => {
85
127
  if (!googleConfigured()) return sendNotOffered(res);
86
128
  // Begun on builders./status.? Restart on the canonical host, where the
87
129
  // callback lands and can read the host-only state cookie back.
@@ -93,8 +135,24 @@ module.exports = function buildAuthGoogleRouter() {
93
135
  }
94
136
  const state = crypto.randomBytes(16).toString('hex');
95
137
  const nonce = crypto.randomBytes(16).toString('hex');
96
- handshakeCookie(req, res, GOOGLE_STATE_COOKIE, `${state}.${nonce}`);
97
- const returnTo = authRoutes.safeReturnTo(req.query.return) || authRoutes.safeReturnTo(req.headers.referer);
138
+ // Link Google to the signed-in person (task 1004995). Google matches redirect
139
+ // URIs exactly, so a link shares this handshake and the callback reads the
140
+ // flag back from the host-only state cookie, never from the query.
141
+ if (req.query.link === '1') {
142
+ if (!authLink.hubLinker()) return sendNotOffered(res);
143
+ let session = null;
144
+ try { session = await auth.resolveSession(req); } catch (err) { log.error({ err: err && err.message }, '[auth] google link start session'); }
145
+ if (!session || session.status === 'inactive') return res.redirect('/account');
146
+ handshakeCookie(req, res, GOOGLE_STATE_COOKIE, `${state}.${nonce}.link`);
147
+ return res.redirect(buildGoogleAuthorizeUrl({ state, nonce, redirectUri: googleRedirectUri() }));
148
+ }
149
+ // `fresh=1` is "Make a new account with Google" on the existing-account hint
150
+ // (task 1004995): the person has seen the hint, so it is not shown again, and
151
+ // the return target set by the first attempt is kept (the referrer is now
152
+ // the callback, which is no place to land).
153
+ const fresh = req.query.fresh === '1';
154
+ handshakeCookie(req, res, GOOGLE_STATE_COOKIE, fresh ? `${state}.${nonce}.fresh` : `${state}.${nonce}`);
155
+ const returnTo = !fresh && (authRoutes.safeReturnTo(req.query.return) || authRoutes.safeReturnTo(req.headers.referer));
98
156
  if (returnTo) handshakeCookie(req, res, authRoutes.RETURN_COOKIE, returnTo);
99
157
  res.redirect(buildGoogleAuthorizeUrl({ state, nonce, redirectUri: googleRedirectUri() }));
100
158
  });
@@ -103,9 +161,10 @@ module.exports = function buildAuthGoogleRouter() {
103
161
  router.get('/auth/google/callback', async (req, res) => {
104
162
  if (!googleConfigured()) return sendNotOffered(res);
105
163
  const startHref = `${authRoutes.apiBase(req)}/auth/google/start`;
106
- const [expectedState, nonce] = String(parseCookie(req, GOOGLE_STATE_COOKIE) || '').split('.');
164
+ const [expectedState, nonce, mode] = String(parseCookie(req, GOOGLE_STATE_COOKIE) || '').split('.');
107
165
  const { state, code, error } = req.query;
108
166
  res.clearCookie(GOOGLE_STATE_COOKIE);
167
+ if (mode === 'link') return finishGoogleLink(req, res, { expectedState, nonce, state, code, error });
109
168
  if (error) {
110
169
  // access_denied is the person pressing Cancel at Google: not a fault.
111
170
  return res.status(400).type('text/html').send(authRoutes.renderAuthErrorPage(
@@ -123,6 +182,13 @@ module.exports = function buildAuthGoogleRouter() {
123
182
  }
124
183
  try {
125
184
  const identity = await exchangeGoogleCode({ code, redirectUri: googleRedirectUri(), nonce });
185
+ if (mode !== 'fresh' && await suggestsExistingGithub(identity)) {
186
+ const base = authRoutes.apiBase(req);
187
+ return res.type('text/html').send(renderExistingAccountHint({
188
+ githubHref: `${base}/auth/web/start?via=github&return=${encodeURIComponent('/account#aWaysIn')}`,
189
+ newAccountHref: `${base}/auth/google/start?fresh=1`,
190
+ }));
191
+ }
126
192
  const { token, expiresAt, created } = await completeGoogleFlow({ identity, userAgent: req.headers['user-agent'] });
127
193
  authRoutes.setSessionCookie(req, res, token, expiresAt);
128
194
  const returnTo = authRoutes.safeReturnTo(parseCookie(req, authRoutes.RETURN_COOKIE));
@@ -0,0 +1,132 @@
1
+ // src/bongos/routes/auth-link.js — linking a second way in to the signed-in
2
+ // person: Link GitHub here, Link Google through the Google routes (task 1004995).
3
+ //
4
+ // A link is a sign-in with the other account, made while already signed in. It
5
+ // never merges: a way in that already reaches another person is refused, and the
6
+ // account page says so plainly (owner, 2026-10-29). Both answers land on
7
+ // /account, which owns the words:
8
+ //
9
+ // GET /auth/link/github/start → GitHub's account picker (state cookie)
10
+ // GET /auth/web/callback/link → checks the state, trades the code, links
11
+ //
12
+ // The GitHub callback is a SUBDIRECTORY of the sign-in callback on purpose:
13
+ // GitHub accepts any redirect_uri below the registered one, so linking needs no
14
+ // change to the owner's GitHub app. (Google matches redirect URIs exactly, so its
15
+ // link rides the sign-in callback with a flag — routes/auth-google.js.)
16
+ //
17
+ // Linking needs the hub (the platform-identity.hub port's linkLogin). Off the hub
18
+ // every route here answers "not offered".
19
+ 'use strict';
20
+
21
+ const express = require('express');
22
+ const crypto = require('node:crypto');
23
+
24
+ const auth = require('../auth');
25
+ const db = require('../db');
26
+ const seams = require('../../module-seams');
27
+ const { parseCookie } = require('./_helpers');
28
+ const { buildAuthorizeUrl, exchangeCodeForToken, fetchGithubUser } = require('../auth-github');
29
+ const authRoutes = require('./auth');
30
+ const log = require('../logger').logger.child({ src: 'auth-link' });
31
+
32
+ const LINK_STATE_COOKIE = 'bongos_link_state';
33
+ const LINK_CALLBACK_PATH = '/api/bongos/auth/web/callback/link';
34
+
35
+ function linkRedirectUri() {
36
+ return `${authRoutes.webRedirectOrigin().replace(/\/+$/, '')}${LINK_CALLBACK_PATH}`;
37
+ }
38
+
39
+ // The hub's linkLogin, or null where linking is not offered.
40
+ function hubLinker() {
41
+ const hub = seams.resolveOptional('platform-identity.hub');
42
+ return hub && typeof hub.linkLogin === 'function' ? hub : null;
43
+ }
44
+
45
+ // Where the account page shows a link's answer. Pure; exported for the test.
46
+ // `out` is linkLogin's answer, or { refused: 'taken' } decided here.
47
+ function linkLanding(provider, out) {
48
+ const q = out && out.linked ? `linked=${provider}` : `link_refused=${(out && out.refused) || 'failed'}&provider=${provider}`;
49
+ return `/account?${q}#aWaysIn`;
50
+ }
51
+
52
+ // Link a way in to the signed-in person, then keep their builder row's login in
53
+ // step for GitHub (the hub shows the real login once GitHub is linked, ADR 0396).
54
+ async function linkToSession({ session, provider, subject, email, githubLogin }) {
55
+ const out = await hubLinker().linkLogin({ personKey: String(session.github_id), provider, subject, email, githubLogin });
56
+ if (out.linked && provider === 'github' && githubLogin) {
57
+ await db.upsertBuilder({
58
+ githubId: String(session.github_id),
59
+ githubLogin,
60
+ displayName: session.display_name || githubLogin,
61
+ avatarUrl: session.avatar_url || null,
62
+ });
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function notOffered(res) {
68
+ return res.status(404).type('text/html').send(authRoutes.renderAuthErrorPage(
69
+ 'Linking isn’t offered here',
70
+ 'This site does not keep more than one way in per account.',
71
+ ));
72
+ }
73
+
74
+ function isSecure(req) {
75
+ return !!(req.secure || req.headers['x-forwarded-proto'] === 'https');
76
+ }
77
+
78
+ module.exports = function buildAuthLinkRouter() {
79
+ const router = express.Router();
80
+
81
+ // rank: public — an OAuth entry point that gates itself: with no live session it
82
+ // sends the person to /account, so only a signed-in person ever links (ADR 0396).
83
+ router.get('/auth/link/github/start', async (req, res) => {
84
+ if (!hubLinker()) return notOffered(res);
85
+ let session = null;
86
+ try { session = await auth.resolveSession(req); } catch (err) { log.error({ err: err && err.message }, '[auth] link start session'); }
87
+ if (!session || session.status === 'inactive') return res.redirect('/account');
88
+ const state = crypto.randomBytes(16).toString('hex');
89
+ res.cookie(LINK_STATE_COOKIE, state, { httpOnly: true, sameSite: 'lax', secure: isSecure(req), maxAge: authRoutes.HANDSHAKE_COOKIE_MS });
90
+ // The account picker, so the person can choose a GitHub account other than
91
+ // the one this browser is signed in to at github.com.
92
+ res.redirect(buildAuthorizeUrl(state, linkRedirectUri(), { switchAccount: true }));
93
+ });
94
+
95
+ // rank: public — the OAuth callback for linking; the state cookie + code are the
96
+ // bearer, and the live session cookie names the person (no session: /account).
97
+ router.get('/auth/web/callback/link', async (req, res) => {
98
+ if (!hubLinker()) return notOffered(res);
99
+ const expected = parseCookie(req, LINK_STATE_COOKIE);
100
+ res.clearCookie(LINK_STATE_COOKIE);
101
+ const { state, code, error } = req.query;
102
+ if (error || !code || !state || !expected || state !== expected) {
103
+ return res.redirect(linkLanding('github', { refused: error === 'access_denied' ? 'cancelled' : 'expired' }));
104
+ }
105
+ let session = null;
106
+ try { session = await auth.resolveSession(req); } catch (err) { log.error({ err: err && err.message }, '[auth] link callback session'); }
107
+ if (!session || session.status === 'inactive') return res.redirect('/account');
108
+ try {
109
+ const { accessToken } = await exchangeCodeForToken(code, linkRedirectUri());
110
+ const ghUser = await fetchGithubUser(accessToken);
111
+ // A GitHub account with its own builder row here is its own account, with
112
+ // its own credits and history: say so, never merge (owner, 2026-10-29).
113
+ if (String(ghUser.id) !== String(session.github_id) && await db.getBuilderByGithubId(String(ghUser.id))) {
114
+ return res.redirect(linkLanding('github', { refused: 'taken' }));
115
+ }
116
+ const out = await linkToSession({
117
+ session, provider: 'github', subject: String(ghUser.id),
118
+ email: typeof ghUser.email === 'string' ? ghUser.email : null, githubLogin: ghUser.login,
119
+ });
120
+ return res.redirect(linkLanding('github', out));
121
+ } catch (err) {
122
+ log.error({ err: err && err.message }, '[auth] web/callback/link');
123
+ return res.redirect(linkLanding('github', { refused: 'failed' }));
124
+ }
125
+ });
126
+
127
+ return router;
128
+ };
129
+
130
+ module.exports.linkLanding = linkLanding;
131
+ module.exports.linkToSession = linkToSession;
132
+ module.exports.hubLinker = hubLinker;
@@ -24,6 +24,7 @@ const buildFeedbackRouter = require('./routes/feedback');
24
24
  const buildAuthRouter = require('./routes/auth');
25
25
  // Google sign-in + the browser page that approves a CLI sign-in (task 1004993).
26
26
  const buildAuthGoogleRouter = require('./routes/auth-google');
27
+ const buildAuthLinkRouter = require('./routes/auth-link');
27
28
  const buildGithubRouter = require('./routes/github');
28
29
  const buildMeRouter = require('./routes/me');
29
30
  // The own-scoped session self-service (list / revoke one / sign out everywhere,
@@ -170,6 +171,7 @@ function buildGdsRouter() {
170
171
  router.use(buildFeedbackRouter());
171
172
  router.use(buildAuthRouter());
172
173
  router.use(buildAuthGoogleRouter());
174
+ router.use(buildAuthLinkRouter());
173
175
  router.use(buildGithubRouter());
174
176
  router.use(buildMeRouter());
175
177
  router.use(buildMySessionsRouter());
@@ -75,6 +75,25 @@ function renderSignInChooser({ githubHref, googleHref }) {
75
75
  });
76
76
  }
77
77
 
78
+ // A first Google sign-in whose address an account that signs in with GitHub
79
+ // already shows (task 1004995). Nothing is joined and the page names no one: it
80
+ // offers the account the person may already have, where they can link Google,
81
+ // or a new, separate account. The second button restarts the Google sign-in
82
+ // with the hint switched off, because the code Google sent is spent.
83
+ function renderExistingAccountHint({ githubHref, newAccountHref }) {
84
+ return renderPage({
85
+ title: 'sign in',
86
+ heading: 'You may already have an account',
87
+ subtitle: 'An account that signs in with GitHub uses the same email as this Google account.',
88
+ bodyHtml: ` <p>If that account is yours, sign in with GitHub, then add Google under <b>ways to sign in</b> on your account page. Both will open the same account, with the same credits and history.</p>
89
+ <div class="signinChoices">
90
+ <a class="landing__btn" href="${escapeHtml(githubHref)}">${GITHUB_MARK}Sign in with GitHub</a>
91
+ <a class="landing__btn" href="${escapeHtml(newAccountHref)}">${GOOGLE_MARK}Make a new account with Google</a>
92
+ </div>
93
+ <p class="landing__note">We never join accounts on our own, even when the emails match.</p>`,
94
+ });
95
+ }
96
+
78
97
  // The command-line approval page, in its four states:
79
98
  // 'enter' — no code, or one that is unknown or expired: ask for it
80
99
  // 'signin' — a live code, nobody signed in: offer both ways in
@@ -135,4 +154,4 @@ ${choiceButtons(view)}
135
154
  });
136
155
  }
137
156
 
138
- module.exports = { renderSignInChooser, renderDeviceApprovalPage };
157
+ module.exports = { renderSignInChooser, renderExistingAccountHint, renderDeviceApprovalPage };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');