@bongos/core 1.24.5 → 1.24.7

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 (53) hide show
  1. package/.bongos-core.json +79 -54
  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 +8 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
  8. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
  9. package/docs/api/openapi.json +114 -6
  10. package/docs/api-reference.md +7 -4
  11. package/docs/copy-inventory.md +52 -43
  12. package/docs/copy-registry.json +145 -63
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/page-inventory.json +2 -1
  15. package/docs/page-readings.json +71 -63
  16. package/migrations/core_286_credit_log_no_spend.sql +128 -0
  17. package/modules/copy-desk/auto-close.js +5 -3
  18. package/modules/copy-desk/page-status.js +7 -4
  19. package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
  20. package/modules/hall-ui/public/project-settings.html +4 -4
  21. package/modules/hall-ui/public/project-settings.js +51 -1
  22. package/modules/lifecycle/autopickup.js +26 -9
  23. package/modules/lifecycle/db-claims.js +1 -1
  24. package/modules/lifecycle/routes/claims.js +7 -1
  25. package/modules/lifecycle/routes/project-settings.js +71 -10
  26. package/modules/platform-identity/account-logins.js +131 -0
  27. package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
  28. package/modules/platform-identity/routes/profile.js +29 -1
  29. package/modules/platform-identity/routes/sso.js +25 -3
  30. package/modules/public-landing/public/account.html +114 -0
  31. package/modules/public-landing/public/account.states.json +1 -0
  32. package/modules/ui-design/kit/serve.js +24 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +24 -0
  36. package/scripts/bongos/run-unit-tests.js +4 -0
  37. package/src/bongos/auth-github.js +24 -7
  38. package/src/bongos/project-settings.js +59 -15
  39. package/src/bongos/routes/auth-google.js +71 -5
  40. package/src/bongos/routes/auth-link.js +132 -0
  41. package/src/bongos/routes.js +2 -0
  42. package/src/bongos/sign-in-pages.js +20 -1
  43. package/src/module-api.js +1 -1
  44. package/tests/account_linking.mjs +289 -0
  45. package/tests/account_logins_db.mjs +56 -0
  46. package/tests/autopickup_cap.mjs +203 -11
  47. package/tests/claims_batch_routes.mjs +79 -12
  48. package/tests/copy_desk_page_release.mjs +20 -0
  49. package/tests/copy_stuck_page_tweaks.mjs +2 -2
  50. package/tests/credit_log_no_spend_db.mjs +160 -0
  51. package/tests/effective_visibility_predicate.mjs +8 -0
  52. package/tests/hub_account_page.mjs +91 -5
  53. package/tests/project_settings_page.mjs +8 -5
@@ -56,11 +56,26 @@ async function countForRound(exec, taskId) {
56
56
  return Number(rows[0] && rows[0].n) || 0;
57
57
  }
58
58
 
59
- // autoPickupRefusal(client, task, settings) — null when the marked claim may
59
+ // effectiveCap(settings, rank) — { cap, from: 'rank' | 'project' } (task 1005003).
60
+ // The claimer's rank's own cap when the project set one, else the project cap. The
61
+ // cap is measured against the project's monthly count either way: a rank cap says
62
+ // how far into the month a runner of that rank may still pick up, not a separate
63
+ // allowance per builder, so the ledger count stays one project-wide number. Pure.
64
+ function effectiveCap(settings, rank) {
65
+ const s = settings || {};
66
+ const caps = s.rank_caps || {};
67
+ if (rank && Object.prototype.hasOwnProperty.call(caps, rank) && Number.isInteger(caps[rank])) {
68
+ return { cap: caps[rank], from: 'rank' };
69
+ }
70
+ return { cap: s.cap, from: 'project' };
71
+ }
72
+
73
+ // autoPickupRefusal(client, task, settings, rank) — null when the marked claim may
60
74
  // proceed, else the refusal object claimTask throws. `settings` is the resolved
61
- // { mode, cap }, read BEFORE the transaction opens (the claimPermissionsFor
62
- // precedent: a settings read is not part of the claim's consistency).
63
- async function autoPickupRefusal(client, task, settings) {
75
+ // { mode, cap, rank_caps }, read BEFORE the transaction opens (the
76
+ // claimPermissionsFor precedent: a settings read is not part of the claim's
77
+ // consistency). `rank` is the claimer's rank, read inside it.
78
+ async function autoPickupRefusal(client, task, settings, rank = null) {
64
79
  if (!task || task.source !== PAGE_TWEAK_SOURCE) {
65
80
  return { code: 'AUTO_PICKUP_NOT_A_TWEAK', message: 'Only a page tweak round can be claimed with the auto_pickup mark.' };
66
81
  }
@@ -77,11 +92,12 @@ async function autoPickupRefusal(client, task, settings) {
77
92
  };
78
93
  }
79
94
  const used = await countThisMonth(client);
80
- if (used >= s.cap) {
81
- return {
82
- code: 'AUTO_PICKUP_CAP_REACHED', used, cap: s.cap,
83
- message: `Auto-pickup has used this month's ${s.cap} rounds, so submitted pages wait for a person until next month.`,
84
- };
95
+ const { cap, from } = effectiveCap(s, rank);
96
+ if (used >= cap) {
97
+ const message = from === 'rank'
98
+ ? `This month's auto-pickups have reached ${cap}, the limit this project sets for a ${rank} builder's runner, so submitted pages wait for a person until next month.`
99
+ : `Auto-pickup has used this month's ${cap} rounds, so submitted pages wait for a person until next month.`;
100
+ return { code: 'AUTO_PICKUP_CAP_REACHED', used, cap, cap_from: from, message };
85
101
  }
86
102
  return null;
87
103
  }
@@ -90,5 +106,6 @@ module.exports = {
90
106
  LOCK_KEY,
91
107
  MAX_AUTO_APPLIES_PER_ROUND,
92
108
  countThisMonth,
109
+ effectiveCap,
93
110
  autoPickupRefusal,
94
111
  };
@@ -374,7 +374,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
374
374
  // limit are counted under a project-wide advisory lock that this transaction
375
375
  // holds through the insert below, so two runners cannot both take the last slot.
376
376
  if (autoPickup) {
377
- const refusal = await autoPickupRefusal(client, task, autoPickupSettings);
377
+ const refusal = await autoPickupRefusal(client, task, autoPickupSettings, builderRank);
378
378
  if (refusal) throw refusal;
379
379
  }
380
380
 
@@ -443,7 +443,13 @@ module.exports = function buildClaimsRouter() {
443
443
  // rank: any-builder — atomic multi-claim; same Xenos row-level filter applies.
444
444
  // V3.R13 (#237): same newcomer gate as POST /claims — a fresh builder can't
445
445
  // sneak around the "one claim at a time" rule by batching.
446
- router.post('/claims/batch', auth.requireBuilder, gateNewcomerClaim, async (req, res) => {
446
+ // task 1005005: and the same gateOneActiveClaimPerSession as POST /claims, in
447
+ // the same order, so a session already holding a claim in this tree cannot
448
+ // batch more work into it. It reads the SESSION's existing active claims (the
449
+ // batch's own rows do not exist yet), keyed on the body's worktree_name — the
450
+ // caller's tree, which a batch also uses as its row-name prefix. A
451
+ // bind_session:false batch (the hall) has no session and passes, as on /claims.
452
+ router.post('/claims/batch', auth.requireBuilder, gateOneActiveClaimPerSession, gateNewcomerClaim, async (req, res) => {
447
453
  if (validateOrRespond(req, res, {
448
454
  task_ids: { required: true, type: 'array', maxItems: 20 },
449
455
  worktree_name: { type: 'string', maxLength: LIMITS.WORKTREE_NAME },
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- // modules/lifecycle/routes/project-settings.js — the five fundamentals of a project,
3
+ // modules/lifecycle/routes/project-settings.js — the seven fundamentals of a project,
4
4
  // in one read, each carrying WHERE its value came from (task 1003280, goal 1000072,
5
5
  // criterion legibility-project-settings).
6
6
  //
@@ -60,6 +60,33 @@ function shipModeRow(resolved) {
60
60
  };
61
61
  }
62
62
 
63
+ // --- the monthly tweak-round cap (task 1005003) -----------------------------
64
+ //
65
+ // The project cap and one optional cap per builder rank, editable on the settings
66
+ // page. The ranks come from the kernel's live ladder, never re-typed here or on the
67
+ // page; a rank with no number follows the project cap.
68
+ function tweakCapRow(resolved) {
69
+ const r = resolved || {};
70
+ const rankCaps = r.rank_caps || {};
71
+ return {
72
+ key: 'tweak_round_cap',
73
+ title: 'Monthly tweak rounds',
74
+ editable: true,
75
+ state: 'live',
76
+ value: {
77
+ cap: r.cap,
78
+ max: api.projectSettings.MAX_AUTOPICKUP_CAP,
79
+ ranks: api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: Object.prototype.hasOwnProperty.call(rankCaps, rank) ? rankCaps[rank] : null })),
80
+ },
81
+ sentence: `Tweak auto-pickup may apply up to ${r.cap} submitted pages a month. A builder rank can have its own limit; `
82
+ + 'a rank left blank follows the project limit. When the limit is reached, pages wait for a person.',
83
+ source: r.source ? r.source.cap : 'default',
84
+ // Only a cap value this row draws; an unusable on/off switch is not this row's.
85
+ invalid: (r.invalid || []).find((i) => /autopickup_(monthly_cap|rank_cap\.)/.test(String(i && i.where))) || null,
86
+ reads: { file: 'project_settings[\'tweak.autopickup_monthly_cap\', \'tweak.autopickup_rank_cap.<rank>\']', adr: 'ADR 0371 D5' },
87
+ };
88
+ }
89
+
63
90
  // callerHolds(builderId, atom) → true | false | null. null means authority could
64
91
  // not be established (government port missing or the resolver threw); a reader
65
92
  // treats that as unknown, never as a grant.
@@ -264,6 +291,7 @@ module.exports = function buildProjectSettingsRouter() {
264
291
  // category the owner cannot see is one they cannot bring back.
265
292
  const categories = await goalCategory.listCategories({ includeInactive: true });
266
293
  const shipMode = await api.projectSettings.resolveShipMode();
294
+ const tweak = await api.projectSettings.resolveTweakAutopickup();
267
295
 
268
296
  // A settings read is a configuration comparison — a cached one is wrong the
269
297
  // moment somebody turns a knob (the rot.js precedent).
@@ -271,6 +299,7 @@ module.exports = function buildProjectSettingsRouter() {
271
299
  res.json({
272
300
  rows: [
273
301
  shipModeRow(shipMode),
302
+ tweakCapRow(tweak),
274
303
  {
275
304
  key: 'rot_timer',
276
305
  title: 'Stale timer',
@@ -443,14 +472,23 @@ module.exports = function buildProjectSettingsRouter() {
443
472
  //
444
473
  // rank: any signed-in builder. The runner reads it before it tries a round, and
445
474
  // the studio shows the count; it carries no reward internals or file paths.
446
- async function autopickupBody() {
475
+ //
476
+ // cap is the project's; rank_caps the per-rank caps the project set (task
477
+ // 1005003). my_cap is the one that applies to the CALLER's rank, and
478
+ // remaining_this_month is measured against it, so a runner reads the number the
479
+ // claim gate will hold it to.
480
+ async function autopickupBody(rank = null) {
447
481
  const resolved = await api.projectSettings.resolveTweakAutopickup();
448
482
  const used = await autopickup.countThisMonth();
483
+ const mine = autopickup.effectiveCap(resolved, rank);
449
484
  return {
450
485
  mode: resolved.mode,
451
486
  cap: resolved.cap,
487
+ rank_caps: resolved.rank_caps || {},
488
+ my_cap: mine.cap,
489
+ my_cap_from: mine.from,
452
490
  used_this_month: used,
453
- remaining_this_month: Math.max(0, resolved.cap - used),
491
+ remaining_this_month: Math.max(0, mine.cap - used),
454
492
  per_round_limit: autopickup.MAX_AUTO_APPLIES_PER_ROUND,
455
493
  source: resolved.source,
456
494
  invalid: resolved.invalid,
@@ -461,32 +499,54 @@ module.exports = function buildProjectSettingsRouter() {
461
499
  router.get('/project-settings/tweak-autopickup', api.requireBuilder, async (req, res) => {
462
500
  try {
463
501
  res.set('Cache-Control', 'no-store');
464
- res.json(await autopickupBody());
502
+ res.json(await autopickupBody(req.builder && req.builder.rank));
465
503
  } catch (err) {
466
504
  log.error(`GET /project-settings/tweak-autopickup: ${err && err.message}`);
467
505
  res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
468
506
  }
469
507
  });
470
508
 
471
- // PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n }.
509
+ // PATCH /project-settings/tweak-autopickup — body { mode?: 'off' | 'on', cap?: n,
510
+ // rank_caps?: { <rank>: n | null } }.
472
511
  //
473
512
  // rank: project.settings.manage (metic+, government_010), the same atom as the
474
513
  // other writes on this page. Each field is validated by the resolver's own parser,
475
- // and both are checked before either is written, so a half-valid body writes nothing.
514
+ // and all are checked before any is written, so a half-valid body writes nothing.
515
+ //
516
+ // rank_caps (task 1005003) is a PARTIAL update: a rank named with a number gets
517
+ // that cap, a rank named with null loses its own cap (and follows the project cap
518
+ // again), and a rank not named is left as it was. A rank must be on the live
519
+ // ladder; each number goes through parseAutopickupCap, the project cap's parser.
476
520
  router.patch('/project-settings/tweak-autopickup', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
477
- if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' } })) return;
521
+ if (api.validateOrRespond(req, res, { mode: { type: 'string' }, cap: { type: 'number' }, rank_caps: { type: 'object' } })) return;
478
522
  const body = req.body || {};
479
- if (body.mode == null && body.cap == null) {
480
- return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, or both' });
523
+ if (body.mode == null && body.cap == null && body.rank_caps == null) {
524
+ return res.fail('bad_tweak_autopickup', { status: 400, message: 'send mode, cap, rank_caps, or any of them' });
481
525
  }
482
526
  const mode = body.mode == null ? null : api.projectSettings.parseAutopickup(body.mode);
483
527
  if (mode && !mode.mode) return res.fail('bad_tweak_autopickup', { status: 400, message: `mode: ${mode.reason}` });
484
528
  const cap = body.cap == null ? null : api.projectSettings.parseAutopickupCap(body.cap);
485
529
  if (cap && cap.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `cap: ${cap.reason}` });
530
+ const rankEdits = [];
531
+ for (const [rank, value] of Object.entries(body.rank_caps || {})) {
532
+ if (!api.LIVE_RANK_LADDER.includes(rank)) {
533
+ return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps: unknown rank '${rank}' (one of ${api.LIVE_RANK_LADDER.join(' | ')})` });
534
+ }
535
+ if (value === null) { rankEdits.push([rank, null]); continue; }
536
+ const got = api.projectSettings.parseAutopickupCap(value);
537
+ if (got.cap == null) return res.fail('bad_tweak_autopickup', { status: 400, message: `rank_caps.${rank}: ${got.reason}` });
538
+ rankEdits.push([rank, got.cap]);
539
+ }
486
540
  try {
487
541
  if (mode) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP, mode.mode, req.builder.id);
488
542
  if (cap) await api.projectSettings.set(api.projectSettings.KEYS.TWEAK_AUTOPICKUP_CAP, cap.cap, req.builder.id);
489
- res.json({ ok: true, ...(await autopickupBody()) });
543
+ // One row per rank, each a single upsert: no read-modify-write, so two people
544
+ // editing two ranks at once cannot drop each other's edit. '' clears a rank
545
+ // (the resolver reads an empty value as "no cap of its own").
546
+ for (const [rank, n] of rankEdits) {
547
+ await api.projectSettings.set(`${api.projectSettings.KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`, n === null ? '' : n, req.builder.id);
548
+ }
549
+ res.json({ ok: true, ...(await autopickupBody(req.builder && req.builder.rank)) });
490
550
  } catch (err) {
491
551
  log.error(`PATCH /project-settings/tweak-autopickup: ${err && err.message}`);
492
552
  res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
@@ -498,3 +558,4 @@ module.exports = function buildProjectSettingsRouter() {
498
558
 
499
559
  module.exports.settingsDeskRows = settingsDeskRows;
500
560
  module.exports.shipModeRow = shipModeRow;
561
+ module.exports.tweakCapRow = tweakCapRow;
@@ -192,6 +192,131 @@ async function listLoginsForPerson(personKey, { pool = defaultPool } = {}) {
192
192
  return rows;
193
193
  }
194
194
 
195
+ // ── Linking (task 1004995) ──────────────────────────────────────────────────
196
+ // A signed-in person adds a second way in by signing in with it. Never a merge:
197
+ // a way in that already reaches ANOTHER person is refused with 'taken', because
198
+ // both accounts may hold credits, ranks and history and joining them is a
199
+ // decision the server must not make on anyone's behalf (owner, 2026-10-29).
200
+
201
+ // Who signs in with this GitHub user id? → the person key, or null when no one
202
+ // does yet. Every GitHub sign-in on the hub asks this first, so a GitHub account
203
+ // linked to someone who joined with Google reaches THEM, not a second person
204
+ // keyed on the GitHub id (the ADR 0393 carry-over).
205
+ async function personKeyForGithub(githubId, { pool = defaultPool } = {}) {
206
+ const person = await findPersonByLogin({ provider: 'github', subject: githubId }, { pool });
207
+ return person ? person.personKey : null;
208
+ }
209
+
210
+ // Add a way in to the signed-in person → { linked: true, already } or
211
+ // { refused: 'taken' | 'already_linked' | 'no_account' }. For GitHub the linked
212
+ // login is also written on the account (ADR 0393 §4: NULL until linking sets it).
213
+ async function linkLogin({ personKey, provider, subject, email, githubLogin } = {}, { pool = defaultPool } = {}) {
214
+ const l = normalizeLogin({ provider, subject });
215
+ if (!l || !pi.isNumericGithubId(personKey)) throw new Error('linkLogin: not a valid person or provider identity');
216
+ const owner = await findPersonByLogin(l, { pool });
217
+ if (owner) return owner.personKey === String(personKey) ? { linked: true, already: true } : { refused: 'taken' };
218
+ try {
219
+ return await withTx(async (client) => {
220
+ const { rows } = await client.query(
221
+ `SELECT id FROM platform_identity_accounts WHERE github_id = $1`, [String(personKey)],
222
+ );
223
+ if (!rows[0]) return { refused: 'no_account' };
224
+ await client.query(
225
+ `INSERT INTO platform_identity_account_logins (account_id, provider, subject, email, last_used_at)
226
+ VALUES ($1, $2, $3, $4, now())`,
227
+ [rows[0].id, l.provider, l.subject, cleanEmail(email)],
228
+ );
229
+ if (l.provider === 'github') {
230
+ await client.query(
231
+ `UPDATE platform_identity_accounts SET github_login = $2 WHERE id = $1`,
232
+ [rows[0].id, typeof githubLogin === 'string' && githubLogin ? githubLogin.slice(0, 100) : null],
233
+ );
234
+ }
235
+ return { linked: true, already: false };
236
+ }, { pool });
237
+ } catch (err) {
238
+ if (err && err.code === '23505') {
239
+ // Which UNIQUE fired decides the answer: another person took this way in
240
+ // a moment ago, or this person already holds one for the provider.
241
+ const raced = await findPersonByLogin(l, { pool });
242
+ if (raced && raced.personKey !== String(personKey)) return { refused: 'taken' };
243
+ if (raced) return { linked: true, already: true };
244
+ return { refused: 'already_linked' };
245
+ }
246
+ throw err;
247
+ }
248
+ }
249
+
250
+ // Why this way in cannot be removed, or null when it can. Never the last way in
251
+ // (owner, 2026-10-29); and never GitHub from an account born with it, because
252
+ // that account's key IS its GitHub id, so a GitHub sign-in would still reach it.
253
+ function unlinkRefusal({ personKey, provider, count }) {
254
+ if (provider === 'github' && !isMintedPersonKey(personKey)) return 'born_with';
255
+ if (count <= 1) return 'last_way_in';
256
+ return null;
257
+ }
258
+
259
+ // Remove a way in from the signed-in person → { unlinked: true } or
260
+ // { refused: 'not_linked' | 'last_way_in' | 'born_with' }. The count and the
261
+ // delete share one transaction, with the rows locked, so two racing removals
262
+ // cannot both pass the last-way-in check.
263
+ async function unlinkLogin({ personKey, provider } = {}, { pool = defaultPool } = {}) {
264
+ if (!LOGIN_PROVIDERS.includes(provider) || !pi.isNumericGithubId(personKey)) return { refused: 'not_linked' };
265
+ return withTx(async (client) => {
266
+ const { rows } = await client.query(
267
+ `SELECT l.id, l.provider, a.id AS account_id
268
+ FROM platform_identity_account_logins l
269
+ JOIN platform_identity_accounts a ON a.id = l.account_id
270
+ WHERE a.github_id = $1
271
+ FOR UPDATE OF l`,
272
+ [String(personKey)],
273
+ );
274
+ const row = rows.find((r) => r.provider === provider);
275
+ if (!row) return { refused: 'not_linked' };
276
+ const why = unlinkRefusal({ personKey: String(personKey), provider, count: rows.length });
277
+ if (why) return { refused: why };
278
+ await client.query(`DELETE FROM platform_identity_account_logins WHERE id = $1`, [row.id]);
279
+ if (provider === 'github') {
280
+ await client.query(`UPDATE platform_identity_accounts SET github_login = NULL WHERE id = $1`, [row.account_id]);
281
+ }
282
+ return { unlinked: true };
283
+ }, { pool });
284
+ }
285
+
286
+ // A first sign-in whose address another way in already carries → the providers
287
+ // to suggest instead, e.g. ['github'], or [] (task 1004995). Asked only while
288
+ // this provider identity reaches no one yet, so the person can sign in with the
289
+ // account they already have and link this one there, rather than make a second
290
+ // account they would never be able to join to the first. Only provider NAMES
291
+ // come back — never who, never the address — and nothing is joined: owning a
292
+ // matching address must not reach anyone's account (owner, 2026-10-29). This is
293
+ // the one read that compares emails.
294
+ async function existingAccountHint({ provider, subject, email } = {}, { pool = defaultPool } = {}) {
295
+ const l = normalizeLogin({ provider, subject });
296
+ const e = cleanEmail(email);
297
+ if (!l || !e) return [];
298
+ if (await findPersonByLogin(l, { pool })) return [];
299
+ const { rows } = await pool.query(
300
+ `SELECT DISTINCT provider FROM platform_identity_account_logins
301
+ WHERE provider <> $1 AND lower(email) = lower($2)`,
302
+ [l.provider, e],
303
+ );
304
+ return LOGIN_PROVIDERS.filter((p) => rows.some((r) => r.provider === p));
305
+ }
306
+
307
+ // The hub's own GitHub sign-in stamps its way in as used, and keeps the address
308
+ // GitHub showed (its public one), so a later Google sign-in can be offered a
309
+ // link. Display and suggestion only, like every email here.
310
+ async function touchGithubLogin({ githubId, email } = {}, { pool = defaultPool } = {}) {
311
+ const l = normalizeLogin({ provider: 'github', subject: githubId });
312
+ if (!l) return;
313
+ await pool.query(
314
+ `UPDATE platform_identity_account_logins SET last_used_at = now(), email = COALESCE($2, email)
315
+ WHERE provider = 'github' AND subject = $1`,
316
+ [l.subject, cleanEmail(email)],
317
+ );
318
+ }
319
+
195
320
  module.exports = {
196
321
  LOGIN_PROVIDERS,
197
322
  normalizeLogin,
@@ -202,4 +327,10 @@ module.exports = {
202
327
  createAccountForLogin,
203
328
  signInWithLogin,
204
329
  listLoginsForPerson,
330
+ personKeyForGithub,
331
+ linkLogin,
332
+ unlinkRefusal,
333
+ unlinkLogin,
334
+ existingAccountHint,
335
+ touchGithubLogin,
205
336
  };
@@ -0,0 +1,19 @@
1
+ -- platform_identity_035 — index the ways in by address, for the one email read
2
+ -- (task 1004995, ADR 0396 amendment "linking").
3
+ --
4
+ -- existingAccountHint (account-logins.js) asks, on a first Google sign-in, whether
5
+ -- another way in shows the address Google verified, so the person can be offered
6
+ -- the account they already have instead of a second one. It compares
7
+ -- lower(email) = lower($2), which no index served: every first Google sign-in
8
+ -- would scan the whole table. This partial expression index serves exactly that
9
+ -- read and skips the rows with no address (most GitHub ways in).
10
+ --
11
+ -- Additive and idempotent: an index only, no column or constraint changes, so a
12
+ -- reader that never applies this file still works, only slower.
13
+
14
+ CREATE INDEX IF NOT EXISTS platform_identity_account_logins_lower_email_idx
15
+ ON platform_identity_account_logins (lower(email))
16
+ WHERE email IS NOT NULL;
17
+
18
+ COMMENT ON INDEX platform_identity_account_logins_lower_email_idx IS
19
+ 'Serves existingAccountHint''s lower(email) match on a first Google sign-in (task 1004995). A suggestion read only: no sign-in or link is decided by an email.';
@@ -239,9 +239,17 @@ module.exports = function profileRoutes() {
239
239
  // ADR 0393): GitHub, Google, or both, with the address each provider vouched
240
240
  // for. Keyed on the authenticated github_id (the person key); the provider's
241
241
  // user id never crosses. A system:* principal has no platform account: [].
242
+ //
243
+ // Each method carries `unlink_refused` (null, 'last_way_in' or 'born_with') so the
244
+ // page can say why a Remove is missing (task 1004995).
242
245
  router.get('/me/sign-in-methods', requireBuilder, async (req, res) => {
243
246
  try {
244
- const methods = await logins.listLoginsForPerson(req.builder.github_id, { pool });
247
+ const key = req.builder.github_id;
248
+ const rows = await logins.listLoginsForPerson(key, { pool });
249
+ const methods = rows.map((m) => ({
250
+ ...m,
251
+ unlink_refused: logins.unlinkRefusal({ personKey: String(key), provider: m.provider, count: rows.length }),
252
+ }));
245
253
  res.set('Cache-Control', 'no-store');
246
254
  res.json({ methods, providers: logins.LOGIN_PROVIDERS });
247
255
  } catch (err) {
@@ -250,5 +258,25 @@ module.exports = function profileRoutes() {
250
258
  }
251
259
  });
252
260
 
261
+ // rank: any-builder — remove one way in from the caller's OWN account (task
262
+ // 1004995), keyed on the authenticated person key. Never the last way in, and
263
+ // never GitHub from an account born with it (its key is its GitHub id). Adding
264
+ // a way in is a sign-in, so it lives with the sign-in routes, not here.
265
+ router.delete('/me/sign-in-methods/:provider', requireBuilder, async (req, res) => {
266
+ const provider = String(req.params.provider || '');
267
+ if (!logins.LOGIN_PROVIDERS.includes(provider)) return res.fail('unknown_provider', 404);
268
+ try {
269
+ const out = await logins.unlinkLogin({ personKey: req.builder.github_id, provider }, { pool });
270
+ res.set('Cache-Control', 'no-store');
271
+ if (out.unlinked) return res.json({ ok: true });
272
+ if (out.refused === 'not_linked') return res.fail('not_linked', { status: 404, message: 'that way in is not linked to your account' });
273
+ if (out.refused === 'last_way_in') return res.fail('last_way_in', { status: 409, message: 'this is your only way in, so it cannot be removed — link another first' });
274
+ return res.fail('born_with', { status: 409, message: 'your account was made with GitHub, so GitHub stays linked' });
275
+ } catch (err) {
276
+ api.logger('platform-identity').error(`DELETE /me/sign-in-methods/:provider failed: ${err && err.message}`);
277
+ res.fail('sign_in_method_unlink_failed', { status: 500, message: 'internal error' });
278
+ }
279
+ });
280
+
253
281
  return router;
254
282
  };
@@ -32,7 +32,8 @@ const guildEngagements = require('../guild-engagements');
32
32
  const instanceFeedback = require('../instance-feedback');
33
33
  const projectStats = require('../project-stats');
34
34
  // Ways in other than GitHub (task 1004993, ADR 0393/0396).
35
- const { signInWithLogin, hallTakesPersonKey } = require('../account-logins');
35
+ const logins = require('../account-logins');
36
+ const { signInWithLogin, hallTakesPersonKey } = logins;
36
37
 
37
38
  // What a Google-only person reads when a project's hall runs a core too old to
38
39
  // take them (task 1004994).
@@ -243,8 +244,21 @@ function registerSeams() {
243
244
  // a human accepts the platform terms, beside /sso/authorize. Core passes the
244
245
  // identity GitHub returned for the code it exchanged, never a request field,
245
246
  // and `activate: true` is a source literal here exactly as it is there.
246
- recordHubSignin: ({ githubId, githubLogin, displayName, avatarUrl }) => pi.upsertAccount({
247
- githubId, githubLogin, displayName, avatarUrl, activate: true,
247
+ // `githubId` is the PERSON key the sign-in resolved (task 1004995: a GitHub
248
+ // account linked to someone who joined with Google reaches their key), and
249
+ // `githubUserId` + `email` stamp the GitHub way in itself as used.
250
+ recordHubSignin: async ({ githubId, githubLogin, displayName, avatarUrl, githubUserId, email }) => {
251
+ await pi.upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate: true }, { pool });
252
+ await logins.touchGithubLogin({ githubId: githubUserId || githubId, email }, { pool });
253
+ },
254
+ // task 1004995: who a GitHub sign-in reaches (null = no one yet, so the
255
+ // GitHub id itself), and linking a second way in to the signed-in person.
256
+ personKeyForGithub: (githubUserId) => logins.personKeyForGithub(githubUserId, { pool }),
257
+ // A first Google sign-in whose verified address another way in carries →
258
+ // the providers to offer instead (names only; nothing is joined).
259
+ googleSignInHint: ({ subject, email }) => logins.existingAccountHint({ provider: 'google', subject, email }, { pool }),
260
+ linkLogin: ({ personKey, provider, subject, email, githubLogin }) => logins.linkLogin({
261
+ personKey, provider, subject, email, githubLogin,
248
262
  }, { pool }),
249
263
  // task 1004993 (ADR 0396): the hub's own Google sign-in. Finds the person by
250
264
  // Google's own id or creates them; never by an email. Core passes only what it
@@ -501,6 +515,14 @@ module.exports = function ssoRoutes() {
501
515
  if (out.state !== 'ok') return res.json(out); // pending | slow_down | expired | denied
502
516
  if (!out.client_id) return res.fail('device_poll_failed', { status: 500, message: 'internal error' });
503
517
  const id = out.identity;
518
+ // A GitHub account linked to someone who joined with Google is THAT person
519
+ // (task 1004995). This leg carries no sign of whether the project's core can
520
+ // take their key, so it never mints one for them: it says to sign in to the
521
+ // project in the browser, where /sso/authorize can tell.
522
+ const personKey = await logins.personKeyForGithub(id.github_id);
523
+ if (personKey && personKey !== String(id.github_id)) {
524
+ return res.fail('sign_in_in_browser', { status: 409, message: 'This GitHub account is linked to an account that joined with Google. Sign in to the project in your browser instead.' });
525
+ }
504
526
  await pi.upsertAccount({
505
527
  githubId: id.github_id,
506
528
  githubLogin: id.github_login,
@@ -214,6 +214,22 @@
214
214
  </form>
215
215
  </section>
216
216
 
217
+ <!-- WAYS TO SIGN IN — GET/DELETE /me/sign-in-methods (task 1004995). Linking is
218
+ a sign-in with the other account, so Link is a link to the sign-in routes,
219
+ and their answer comes back here as ?linked= or ?link_refused=. A way in
220
+ the server says cannot be removed shows why instead of a Remove. The rows
221
+ are cosmos.css's .mineRow, as under "projects you build on". -->
222
+ <section class="aSec" id="aWaysIn" aria-labelledby="aWaysIn-h">
223
+ <h2 id="aWaysIn-h">ways to sign in</h2>
224
+ <p class="aSide">GitHub, Google or both. Each one opens this same account, with the same credits, ranks and history.</p>
225
+ <div class="aBody">
226
+ <p class="aWhat" id="waysLoading">Reading your ways to sign in…</p>
227
+ <ul class="mineRows" id="waysList"></ul>
228
+ <p class="finePrint">Linking never joins two accounts. If the other one already has its own account here, we say so and change nothing.</p>
229
+ <p class="projMsg" id="waysMsg" role="status" aria-live="polite"></p>
230
+ </div>
231
+ </section>
232
+
217
233
  <!-- WHO CAN FIND YOU — account_visibility + recruiter_discoverable (privacy
218
234
  spec D1/D3, task 1002966), PATCH /me/cross-project. Ported from the hall's
219
235
  old "Profile privacy" section. The recruiter copy must name EXACTLY what
@@ -378,6 +394,7 @@
378
394
  }
379
395
  loadBuilding();
380
396
  loadGuilds();
397
+ loadWaysIn();
381
398
  }, function () { showAlert('Couldn’t reach the server — check your connection and refresh.'); });
382
399
  }
383
400
 
@@ -697,6 +714,103 @@
697
714
  }, function () { restore(); say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
698
715
  }
699
716
 
717
+ // ---- ways to sign in (task 1004995). Read once the account is shown. A
718
+ // link's answer arrives in the address (?linked= / ?link_refused=) from the
719
+ // sign-in routes, and is said here in the page's own words. ----
720
+ var PROVIDER_NAME = { github: 'GitHub', google: 'Google' };
721
+ var LINK_START = { github: '/auth/link/github/start', google: '/auth/google/start?link=1' };
722
+ var KEPT_WHY = {
723
+ last_way_in: 'Your only way in, so it stays. Link another to be able to remove it.',
724
+ born_with: 'Your account was made with GitHub, so GitHub stays linked.',
725
+ };
726
+ function linkAnswer() {
727
+ var q = window.location.search || '';
728
+ var linked = /[?&]linked=(github|google)(&|$)/.exec(q);
729
+ if (linked) return { text: PROVIDER_NAME[linked[1]] + ' is linked. You can now sign in with either.', kind: 'ok' };
730
+ var refused = /[?&]link_refused=([a-z_]+)/.exec(q);
731
+ if (!refused) return null;
732
+ var p = /[?&]provider=(github|google)(&|$)/.exec(q);
733
+ var name = p ? PROVIDER_NAME[p[1]] : 'That';
734
+ var words = {
735
+ taken: 'That ' + name + ' account already has its own account here, so it was not linked. We never join two accounts; nothing changed.',
736
+ already_linked: 'Your account already has a ' + name + ' account linked. Remove it first to link a different one.',
737
+ cancelled: 'You cancelled linking ' + name + '. Nothing changed.',
738
+ expired: 'Linking ' + name + ' took too long. Try again.',
739
+ };
740
+ return { text: words[refused[1]] || 'Couldn’t link ' + name + '. Try again.', kind: 'err' };
741
+ }
742
+ function loadWaysIn() {
743
+ var msg = $('waysMsg');
744
+ var answer = linkAnswer();
745
+ if (answer) say(msg, answer.text, answer.kind);
746
+ return request('GET', '/me/sign-in-methods').then(function (res) {
747
+ $('waysLoading').hidden = true;
748
+ if (!res.ok || !res.data) { say(msg, 'Couldn’t read your ways to sign in — refresh in a moment.', 'err'); return; }
749
+ paintWaysIn(res.data.methods || [], res.data.providers || []);
750
+ }, function () {
751
+ $('waysLoading').hidden = true;
752
+ say(msg, 'Couldn’t reach the server — check your connection and refresh.', 'err');
753
+ });
754
+ }
755
+ function paintWaysIn(methods, providers) {
756
+ var ul = $('waysList');
757
+ ul.textContent = '';
758
+ providers.forEach(function (p) {
759
+ var held = methods.filter(function (m) { return m.provider === p; })[0];
760
+ ul.appendChild(wayRow(p, held));
761
+ });
762
+ }
763
+ function wayRow(provider, held) {
764
+ var name = PROVIDER_NAME[provider] || provider;
765
+ var row = rowEl('li', 'mineRow');
766
+ var main = rowEl('div', 'mineRow__main');
767
+ var title = rowEl('div', 'mineRow__name');
768
+ title.textContent = name;
769
+ main.appendChild(title);
770
+ var line = rowEl('span', 'mineRow__host');
771
+ line.textContent = held ? (held.email || 'linked') : 'not linked';
772
+ main.appendChild(line);
773
+ if (held && held.unlink_refused && KEPT_WHY[held.unlink_refused]) {
774
+ var why = rowEl('span', 'aTag');
775
+ why.textContent = KEPT_WHY[held.unlink_refused];
776
+ main.appendChild(why);
777
+ }
778
+ row.appendChild(main);
779
+ var actions = rowEl('div', 'mineRow__actions');
780
+ if (!held) {
781
+ var a = document.createElement('a');
782
+ a.className = 'btn small';
783
+ a.href = API + LINK_START[provider];
784
+ a.textContent = 'Link ' + name;
785
+ actions.appendChild(a);
786
+ } else if (!held.unlink_refused) {
787
+ var btn = document.createElement('button');
788
+ btn.type = 'button';
789
+ btn.className = 'btn small ghost';
790
+ btn.textContent = 'Remove';
791
+ btn.setAttribute('aria-label', 'Remove ' + name);
792
+ btn.addEventListener('click', function () { unlinkWay(provider, name, btn); });
793
+ actions.appendChild(btn);
794
+ }
795
+ row.appendChild(actions);
796
+ return row;
797
+ }
798
+ function unlinkWay(provider, name, btn) {
799
+ var msg = $('waysMsg');
800
+ btn.disabled = true;
801
+ btn.textContent = 'Removing…';
802
+ request('DELETE', '/me/sign-in-methods/' + provider).then(function (res) {
803
+ if (res.status === 401) { btn.disabled = false; btn.textContent = 'Remove'; sessionGone(msg); return; }
804
+ if (!res.ok) {
805
+ btn.disabled = false; btn.textContent = 'Remove';
806
+ say(msg, serverWords(res, 'Couldn’t remove ' + name + '. Try again.'), 'err');
807
+ return;
808
+ }
809
+ say(msg, name + ' is removed. It no longer opens this account.', 'ok');
810
+ loadWaysIn();
811
+ }, function () { btn.disabled = false; btn.textContent = 'Remove'; say(msg, 'Couldn’t reach the server. Try again.', 'err'); });
812
+ }
813
+
700
814
  // The identity read failing is not a sign-out. If the account itself was
701
815
  // read, the chip names it from that answer, so the way out is never lost.
702
816
  load().then(function () { return bar.ready; }).then(function () {
@@ -13,6 +13,7 @@
13
13
  "in-projects-band": { "auth": true, "actions": [["wait", 400], ["click", "#projTrig"], ["wait", 300]], "expect": { "visible": ["#menuProjects", "#mineCell"], "hidden": ["#menuProfile"] } },
14
14
  "in-profile-band": { "auth": true, "actions": [["wait", 400], ["click", "#who"], ["wait", 300]], "expect": { "visible": ["#menuProfile"], "hidden": ["#menuProjects"], "text": [["#bandWhoLogin", "builder"], ["#menuProfile [aria-current=\"page\"]", "Account"]] } },
15
15
  "in-building": { "auth": true, "url": "/account#aBuilding", "actions": [["wait", 500]], "expect": { "visible": ["#aBuilding", "#buildingList"], "hidden": ["#buildingEmpty", "#buildingLoading"], "count": [["#buildingList .mineRow", 2]], "text": [["#buildingList .mineRow:first-child .mineRow__name", "Example World"], ["#buildingList .mineRow:first-child .mineRow__host", "example.com"]], "attr": [["#buildingList .mineRow:first-child button", "aria-label", "Leave Example World"]] } },
16
+ "in-ways": { "auth": true, "url": "/account#aWaysIn", "actions": [["wait", 500]], "expect": { "visible": ["#aWaysIn", "#waysList"], "hidden": ["#waysLoading"], "count": [["#waysList .mineRow", 2]], "text": [["#waysList .mineRow:first-child .mineRow__name", "GitHub"], ["#waysList .mineRow:nth-child(2) .mineRow__host", "octo@example.com"]], "attr": [["#waysList .mineRow:nth-child(2) button", "aria-label", "Remove Google"]] } },
16
17
  "in-privacy": { "auth": true, "actions": [["wait", 400], ["click", "#aPrivacy .aOpt:nth-child(2)"], ["wait", 150]], "expect": { "checked": "input[name=visibility][value=private]", "visible": ["#visGuilds"], "hidden": ["#recordGuilds"], "count": [["#visGuildList li", 2]], "attr": [["#visGuildList li:first-child a", "href", "/g/forge-crew"]], "text": [["#visGuildList li:first-child a", "Forge Crew"], ["#recWhat", "They will see your name, your picture, your roles, and whether you have been active in the last 30 days — never your credits, works shipped, karma, or the projects you build on. This is the one thing a recruiter can see while your profile page is private. Turning this off removes you from both recruiting surfaces — the recruiting directory, and a recruiter’s look-up of one builder — straight away. · For now this follows your profile page setting."]] } }
17
18
  },
18
19
  "reducedMotion": { "auth": true, "actions": [["wait", 300]] }