@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.
- package/.bongos-core.json +79 -54
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -1
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
- package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
- package/docs/api/openapi.json +114 -6
- package/docs/api-reference.md +7 -4
- package/docs/copy-inventory.md +52 -43
- package/docs/copy-registry.json +145 -63
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +71 -63
- package/migrations/core_286_credit_log_no_spend.sql +128 -0
- package/modules/copy-desk/auto-close.js +5 -3
- package/modules/copy-desk/page-status.js +7 -4
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +4 -4
- package/modules/hall-ui/public/project-settings.html +4 -4
- package/modules/hall-ui/public/project-settings.js +51 -1
- package/modules/lifecycle/autopickup.js +26 -9
- package/modules/lifecycle/db-claims.js +1 -1
- package/modules/lifecycle/routes/claims.js +7 -1
- package/modules/lifecycle/routes/project-settings.js +71 -10
- package/modules/platform-identity/account-logins.js +131 -0
- package/modules/platform-identity/migrations/platform_identity_035_account_login_email_idx.sql +19 -0
- package/modules/platform-identity/routes/profile.js +29 -1
- package/modules/platform-identity/routes/sso.js +25 -3
- package/modules/public-landing/public/account.html +114 -0
- package/modules/public-landing/public/account.states.json +1 -0
- package/modules/ui-design/kit/serve.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/auth-github.js +24 -7
- package/src/bongos/project-settings.js +59 -15
- package/src/bongos/routes/auth-google.js +71 -5
- package/src/bongos/routes/auth-link.js +132 -0
- package/src/bongos/routes.js +2 -0
- package/src/bongos/sign-in-pages.js +20 -1
- package/src/module-api.js +1 -1
- package/tests/account_linking.mjs +289 -0
- package/tests/account_logins_db.mjs +56 -0
- package/tests/autopickup_cap.mjs +203 -11
- package/tests/claims_batch_routes.mjs +79 -12
- package/tests/copy_desk_page_release.mjs +20 -0
- package/tests/copy_stuck_page_tweaks.mjs +2 -2
- package/tests/credit_log_no_spend_db.mjs +160 -0
- package/tests/effective_visibility_predicate.mjs +8 -0
- package/tests/hub_account_page.mjs +91 -5
- package/tests/project_settings_page.mjs +8 -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.
|
|
3
|
+
"version": "1.24.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.24.
|
|
9
|
+
"version": "1.24.7",
|
|
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.
|
|
3
|
+
"version": "1.24.7",
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10853,5 +10853,29 @@
|
|
|
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
|
+
}
|
|
10874
|
+
],
|
|
10875
|
+
"1.24.7": [
|
|
10876
|
+
{
|
|
10877
|
+
"id": "1005003",
|
|
10878
|
+
"text": "The automatic tweak limit is now 200 rounds a month, and a project owner can change it, for the whole project and separately for each builder rank, on the project settings page."
|
|
10879
|
+
}
|
|
10856
10880
|
]
|
|
10857
10881
|
}
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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:
|
|
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,
|
|
@@ -52,6 +52,9 @@ const SETTING_KEYS = {
|
|
|
52
52
|
SHIP_MODE: 'ship.mode',
|
|
53
53
|
TWEAK_AUTOPICKUP: 'tweak.autopickup',
|
|
54
54
|
TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
|
|
55
|
+
// Per-rank caps (task 1005003): one row per live rank, this prefix + the rank
|
|
56
|
+
// (rankCapKey). An empty value means the rank follows the project cap.
|
|
57
|
+
TWEAK_AUTOPICKUP_RANK_CAP_PREFIX: 'tweak.autopickup_rank_cap.',
|
|
55
58
|
SPEND_CAP: 'treasury.spend_cap_usd',
|
|
56
59
|
// Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
|
|
57
60
|
MODULE_TESTER: 'modules.tester',
|
|
@@ -292,9 +295,10 @@ function describeShipMode(resolved) {
|
|
|
292
295
|
// (modules/lifecycle/autopickup.js); this file only resolves the numbers.
|
|
293
296
|
const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
|
|
294
297
|
const DEFAULT_AUTOPICKUP = 'off';
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
+
// 200 rounds: the owner's decision on task 1004971 (2026-10-29, Q7), raising ADR
|
|
299
|
+
// 0371 D5's first figure of 20. A project can set its own number, and a cap per
|
|
300
|
+
// builder rank (task 1005003); this is only the answer for a project that has not.
|
|
301
|
+
const DEFAULT_AUTOPICKUP_CAP = 200;
|
|
298
302
|
// Past 1000 the cap is an off switch typed as a big number; that should be asked
|
|
299
303
|
// for out loud, the same reasoning as MAX_ROT_DAYS.
|
|
300
304
|
const MAX_AUTOPICKUP_CAP = 1000;
|
|
@@ -318,6 +322,16 @@ function parseAutopickupCap(raw) {
|
|
|
318
322
|
|
|
319
323
|
// One knob through its parser: { value, source, invalid }. Never throws; an
|
|
320
324
|
// unreadable database answers the default, the resolveShipMode posture.
|
|
325
|
+
// `row` is the already-read row (or undefined); parseRow never touches the DB.
|
|
326
|
+
function parseRow(key, row, parse, pick, fallback) {
|
|
327
|
+
if (row && row.value != null && row.value !== '') {
|
|
328
|
+
const got = parse(row.value);
|
|
329
|
+
if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
|
|
330
|
+
return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
|
|
331
|
+
}
|
|
332
|
+
return { value: fallback, source: 'default', invalid: null };
|
|
333
|
+
}
|
|
334
|
+
|
|
321
335
|
async function resolveOne(key, parse, pick, fallback) {
|
|
322
336
|
let row = null;
|
|
323
337
|
try {
|
|
@@ -326,31 +340,60 @@ async function resolveOne(key, parse, pick, fallback) {
|
|
|
326
340
|
logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
|
|
327
341
|
return { value: fallback, source: 'default', invalid: null };
|
|
328
342
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
343
|
+
return parseRow(key, row, parse, pick, fallback);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// rankCapKey(rank) — the row holding one rank's own cap (task 1005003). ONE ROW
|
|
347
|
+
// PER RANK, deliberately: setting or clearing a rank is a single upsert, so two
|
|
348
|
+
// people editing two different ranks at once can never overwrite each other (a
|
|
349
|
+
// shared JSON object would be read-modify-write and lose one edit). An empty
|
|
350
|
+
// value means "no cap of its own" (the null-is-meaningful rule above).
|
|
351
|
+
function rankCapKey(rank) {
|
|
352
|
+
return `${SETTING_KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`;
|
|
335
353
|
}
|
|
336
354
|
|
|
337
|
-
// resolveTweakAutopickup() — { mode, cap, source: { mode, cap },
|
|
338
|
-
// mode is always 'off' or 'on', cap always a usable positive
|
|
355
|
+
// resolveTweakAutopickup() — { mode, cap, rank_caps, source: { mode, cap },
|
|
356
|
+
// invalid: [...] }. mode is always 'off' or 'on', cap always a usable positive
|
|
357
|
+
// integer, rank_caps always an object of live-ladder rank → cap (empty when no rank
|
|
358
|
+
// has its own). Every row is read in ONE query; each rank cap goes through
|
|
359
|
+
// parseAutopickupCap, the project cap's own parser, so the two cannot disagree.
|
|
339
360
|
async function resolveTweakAutopickup() {
|
|
340
|
-
|
|
341
|
-
const
|
|
361
|
+
// Lazy: the ladder is the kernel's one copy (tests/rank_tier_single_source.mjs).
|
|
362
|
+
const ladder = require('./db-kernel').LIVE_RANK_LADDER;
|
|
363
|
+
const keys = [SETTING_KEYS.TWEAK_AUTOPICKUP, SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, ...ladder.map(rankCapKey)];
|
|
364
|
+
let byKey = new Map();
|
|
365
|
+
try {
|
|
366
|
+
const { rows } = await pool.query('SELECT key, value FROM project_settings WHERE key = ANY($1::text[])', [keys]);
|
|
367
|
+
byKey = new Map(rows.map((row) => [row.key, row]));
|
|
368
|
+
} catch (err) {
|
|
369
|
+
logger.error(`project_settings read failed; falling back to the default tweak auto-pickup settings: ${err && err.message}`);
|
|
370
|
+
}
|
|
371
|
+
const m = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP), parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
|
|
372
|
+
const c = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP), parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
|
|
373
|
+
const rankCaps = {};
|
|
374
|
+
const invalid = [m.invalid, c.invalid];
|
|
375
|
+
for (const rank of ladder) {
|
|
376
|
+
const r = parseRow(rankCapKey(rank), byKey.get(rankCapKey(rank)), parseAutopickupCap, (g) => g.cap, null);
|
|
377
|
+
if (r.value != null) rankCaps[rank] = r.value;
|
|
378
|
+
invalid.push(r.invalid);
|
|
379
|
+
}
|
|
342
380
|
return {
|
|
343
381
|
mode: m.value,
|
|
344
382
|
cap: c.value,
|
|
383
|
+
rank_caps: rankCaps,
|
|
345
384
|
source: { mode: m.source, cap: c.source },
|
|
346
|
-
invalid:
|
|
385
|
+
invalid: invalid.filter(Boolean),
|
|
347
386
|
};
|
|
348
387
|
}
|
|
349
388
|
|
|
350
389
|
function describeTweakAutopickup(resolved) {
|
|
351
390
|
const r = resolved || {};
|
|
352
391
|
if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
|
|
353
|
-
|
|
392
|
+
const ranks = Object.entries(r.rank_caps || {});
|
|
393
|
+
const byRank = ranks.length
|
|
394
|
+
? ` Some builder ranks have their own limit: ${ranks.map(([rank, n]) => `${rank} ${n}`).join(', ')}.`
|
|
395
|
+
: '';
|
|
396
|
+
return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month.${byRank} The artist still approves every one.`;
|
|
354
397
|
}
|
|
355
398
|
|
|
356
399
|
// THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
|
|
@@ -416,6 +459,7 @@ module.exports = {
|
|
|
416
459
|
MAX_AUTOPICKUP_CAP,
|
|
417
460
|
parseAutopickup,
|
|
418
461
|
parseAutopickupCap,
|
|
462
|
+
rankCapKey,
|
|
419
463
|
resolveTweakAutopickup,
|
|
420
464
|
describeTweakAutopickup,
|
|
421
465
|
MAX_SPEND_CAP_USD,
|
|
@@ -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
|
-
|
|
97
|
-
|
|
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;
|
package/src/bongos/routes.js
CHANGED
|
@@ -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.
|
|
84
|
+
const CORE_VERSION = '1.24.7'; // 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');
|