@bongos/core 1.24.4 → 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.
- package/.bongos-core.json +99 -59
- 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 +7 -0
- package/clients/bongos-client/index.mjs +6 -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 +110 -5
- package/docs/api-reference.md +6 -3
- package/docs/copy-inventory.md +47 -43
- package/docs/copy-registry.json +81 -45
- package/docs/file-map.md +3 -2
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +2 -1
- package/docs/page-readings.json +64 -56
- package/migrations/core_286_credit_log_no_spend.sql +128 -0
- package/modules/copy-desk/auto-close.js +51 -11
- package/modules/copy-desk/page-status.js +18 -6
- package/modules/copy-desk/tests/copy_no_cms.mjs +8 -1
- package/modules/copy-desk/tests/copy_page_auto_close.mjs +68 -6
- package/modules/copy-desk/tests/copy_page_status.mjs +5 -1
- package/modules/hall-ui/public/tweak-editor.js +2 -1
- package/modules/lifecycle/db-claims.js +2 -2
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/routes/claims.js +7 -1
- 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 +39 -1
- package/scripts/bongos/codemod-rename-scripts-gds.js +1 -3
- package/scripts/bongos/dev-box-guard.js +0 -1
- package/scripts/bongos/publish-manifest.js +1 -1
- package/scripts/bongos/run-unit-tests.js +4 -0
- package/src/bongos/agent-standing-rules.js +44 -0
- package/src/bongos/auth-github.js +24 -7
- package/src/bongos/module-scope-map.js +3 -2
- package/src/bongos/routes/auth-google.js +71 -5
- package/src/bongos/routes/auth-link.js +132 -0
- package/src/bongos/routes/me.js +4 -1
- 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/agent_standing_rules.mjs +142 -0
- package/tests/claims_batch_routes.mjs +115 -25
- package/tests/copy_desk_page_release.mjs +20 -0
- package/tests/copy_stuck_page_tweaks.mjs +5 -3
- package/tests/credit_log_no_spend_db.mjs +160 -0
- package/tests/effective_visibility_predicate.mjs +8 -0
- package/tests/government_laws_session.mjs +2 -1
- package/tests/hall_tweak_editor.mjs +11 -0
- package/tests/host_topology_skips.mjs +2 -0
- package/tests/hub_account_page.mjs +91 -5
- package/tests/run_node_reroute.mjs +136 -0
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.24.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -10812,7 +10812,27 @@
|
|
|
10812
10812
|
"text": "The payments decision record now carries the owner's answers: credits can be tuned but not switched off, pay rules live on the settings page, and Verro is Verro Labs."
|
|
10813
10813
|
}
|
|
10814
10814
|
],
|
|
10815
|
-
"1.24.
|
|
10815
|
+
"1.24.5": [
|
|
10816
|
+
{
|
|
10817
|
+
"id": "1004973",
|
|
10818
|
+
"text": "A page tweak whose apply stopped partway, with nobody holding it any more, now goes back in the queue by itself with a note saying the apply stopped, instead of sitting stuck forever."
|
|
10819
|
+
},
|
|
10820
|
+
{
|
|
10821
|
+
"id": "1005004",
|
|
10822
|
+
"text": "Fixed a check that could wrongly stop a builder from handing back or finishing their own task when their ID arrived written as a number instead of text."
|
|
10823
|
+
},
|
|
10824
|
+
{
|
|
10825
|
+
"id": "1005015",
|
|
10826
|
+
"text": "AI agents now reliably flag when they need you (HUMAN TASK / HUMAN DECISION) and own up when a doc or tool misled them, on every machine, even ones running out-of-date copies."
|
|
10827
|
+
},
|
|
10828
|
+
{
|
|
10829
|
+
"id": "1004972",
|
|
10830
|
+
"text": "A page tweak someone opened but never wrote in now shows as \"Opened, nothing written yet\" instead of \"Needs to be reviewed\", and closes itself after one day with a note saying nothing was written."
|
|
10831
|
+
},
|
|
10832
|
+
{
|
|
10833
|
+
"id": "1004978",
|
|
10834
|
+
"text": "Deleted an old, out-of-date manifest file from the repository root that nothing used, so old scripts it listed no longer look like they are still in use."
|
|
10835
|
+
},
|
|
10816
10836
|
{
|
|
10817
10837
|
"id": "1004968",
|
|
10818
10838
|
"text": "The setting that sizes credit rewards is now called a cost scale instead of a dollar peg, so it no longer reads like an exchange rate; old project settings keep working and nobody's rewards change."
|
|
@@ -10833,5 +10853,23 @@
|
|
|
10833
10853
|
"id": "1005006",
|
|
10834
10854
|
"text": "The Treasury screen now counts builder rewards in credits instead of dollars, and no longer says one credit is a dollar."
|
|
10835
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
|
+
}
|
|
10836
10874
|
]
|
|
10837
10875
|
}
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
// - files that deliberately name BOTH spellings or the old one: this codemod, forwarder.js,
|
|
53
53
|
// the forwarder test, modules/lifecycle/executed-closure.js (lists both on purpose),
|
|
54
54
|
// rename-ledger-prose.js (masks `scripts/gds` as a protected identifier in ledger prose),
|
|
55
|
-
// the task-2004 codemod (a historical tool)
|
|
56
|
-
// core build cut from an older commit — its paths describe THAT build).
|
|
55
|
+
// and the task-2004 codemod (a historical tool).
|
|
57
56
|
//
|
|
58
57
|
// Usage:
|
|
59
58
|
// node scripts/bongos/codemod-rename-scripts-gds.js # apply
|
|
@@ -81,7 +80,6 @@ const EXCLUDE = new Set([
|
|
|
81
80
|
'tests/scripts_gds_forwarders.mjs',
|
|
82
81
|
'modules/lifecycle/executed-closure.js',
|
|
83
82
|
'scripts/bongos/rename-ledger-prose.js',
|
|
84
|
-
'.bongos-core.json',
|
|
85
83
|
]);
|
|
86
84
|
|
|
87
85
|
const FROZEN_PATHS = [
|
|
@@ -85,7 +85,6 @@ const DEV_BOX_EXEMPT = new Map([
|
|
|
85
85
|
// Captured data and seed history that name real records.
|
|
86
86
|
['scripts/bongos/seed-', 'one-shot task seeders: their subject is the historical tasks they filed'],
|
|
87
87
|
['scripts/hall-preview/fixtures/', 'captured API responses: the ledger still holds these task and goal rows'],
|
|
88
|
-
['.bongos-core.json', 'a 1.0.0 seed manifest from July (commit b6ee5d29) that this repo never regenerates'],
|
|
89
88
|
]);
|
|
90
89
|
|
|
91
90
|
const TEXT_EXT = /\.(js|mjs|cjs|json|md|sql|sh|html|css|yml|yaml|txt|toml)$/;
|
|
@@ -316,7 +316,7 @@ const INSTANCE_EXCLUDE = [
|
|
|
316
316
|
// the instance-recipe / game-design carve-outs above. (The portable ADRs + the README index still publish.)
|
|
317
317
|
// The instance's OWN name + trademark decision. Its prose redacts cleanly, but the FILENAME
|
|
318
318
|
// carries the brand and the publish manifest lists every path — so the literal shipped in
|
|
319
|
-
// .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
319
|
+
// the tarball's inner .bongos-core.json no matter how well the body was redacted. It also genericizes to nothing:
|
|
320
320
|
// "accepting trademark risk on <our name>" is not a decision another instance inherits.
|
|
321
321
|
// (task 1003869 — the last literal in the artifact.)
|
|
322
322
|
'docs/adr/0004-amazonprimea-name-and-trademark-acceptance.md',
|
|
@@ -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.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// src/bongos/agent-standing-rules.js — the rules every AI session obeys whoever the
|
|
3
|
+
// builder is, served by the SERVER so a stale checkout still hears them (task 1005015).
|
|
4
|
+
//
|
|
5
|
+
// WHY THE SERVER. The human signal (task 1004922) lived only in the local Conductor
|
|
6
|
+
// hook, told once on a session's first prompt. A hook runs from whatever checkout the
|
|
7
|
+
// machine has, and on the owner's box that was 823 commits behind, so the markers never
|
|
8
|
+
// appeared at all. Slop detection lived only as a CLAUDE.md table row nothing ever
|
|
9
|
+
// triggered. GET /me's interaction contract is relayed verbatim by EVERY Conductor
|
|
10
|
+
// since task 2010, stale ones included, so appending these rules to it reaches every
|
|
11
|
+
// active user the moment the instance serving them runs this code.
|
|
12
|
+
//
|
|
13
|
+
// The wording is the rule's home for sessions; docs/voice.md is its home for people.
|
|
14
|
+
// conductor.js and stop-slopcheck.js carry their own copies (they must work on a
|
|
15
|
+
// checkout whose core cannot load), and tests/agent_standing_rules.mjs holds every
|
|
16
|
+
// copy equal to the contract this file serves. Only withStandingRules is exported:
|
|
17
|
+
// the hooks' dynamic core resolve is invisible to the dead-code scan.
|
|
18
|
+
|
|
19
|
+
const HUMAN_SIGNAL_TEXT = 'When you need the person, start that line with `🙋 **HUMAN TASK:**` if they must DO something '
|
|
20
|
+
+ 'only a person can do (try it on the live site, add a key, sign in, approve), or `🔀 **HUMAN DECISION:**` if they must '
|
|
21
|
+
+ 'CHOOSE (list the options and mark your pick). Put the same marker at the start of a question-box question. Never use them for anything else.';
|
|
22
|
+
|
|
23
|
+
const SLOP_TEXT = 'The moment you find you were wrong because something told you so (a doc, a hook\'s output, a script\'s message, '
|
|
24
|
+
+ 'a stale file) — or the person corrects you — say `🐛 Bongo Slop Detected!!, Filing Bug 🐛`, name the source that misled you, '
|
|
25
|
+
+ 'and file it: `node scripts/bongos/capture.js "<file>: says X, truth is Y" --kind bug`. The misleading source is the bug. '
|
|
26
|
+
+ 'Your own plain mistake, with nothing misleading behind it, is not slop.';
|
|
27
|
+
|
|
28
|
+
const HEADER = 'Standing rules for every session:';
|
|
29
|
+
|
|
30
|
+
function standingRulesBlock() {
|
|
31
|
+
return `${HEADER}\n- ${HUMAN_SIGNAL_TEXT}\n- ${SLOP_TEXT}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The interaction slice of GET /me with the standing rules appended. `interaction` is
|
|
35
|
+
// describeInteraction's object or null (builder-settings module off); either way the
|
|
36
|
+
// result injects, because these rules do not depend on any preference. Idempotent.
|
|
37
|
+
function withStandingRules(interaction) {
|
|
38
|
+
const base = interaction && typeof interaction === 'object' ? interaction : {};
|
|
39
|
+
const contract = typeof base.contract === 'string' ? base.contract : '';
|
|
40
|
+
if (contract.includes(HEADER)) return { ...base, inject: true };
|
|
41
|
+
return { ...base, contract: contract ? `${contract}\n${standingRulesBlock()}` : standingRulesBlock(), inject: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
module.exports = { withStandingRules };
|
|
@@ -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,
|
|
@@ -615,8 +615,9 @@ const SHARED_GLOBS = Object.freeze(
|
|
|
615
615
|
// Assigning nineteen roots in one pass is the one-at-a-time churn task 1004132 was
|
|
616
616
|
// filed to avoid; freezing the set and naming the arguable ones is the cheap half.
|
|
617
617
|
const UNOWNED_ROOTS = Object.freeze({
|
|
618
|
-
// Generated packaging
|
|
619
|
-
|
|
618
|
+
// Generated packaging manifest — written by package-core.js, never hand-edited.
|
|
619
|
+
// (The repo-root .bongos-core.json, a stale July 1.0.0 seed, was deleted in task 1004978;
|
|
620
|
+
// the live one is the INNER manifest package-core writes into the tarball.)
|
|
620
621
|
'.cloudbongos-core.json': 'generated package manifest (scripts/bongos/package-core.js)',
|
|
621
622
|
// Governance + legal. Deliberately not methodology: methodology owns docs/, and
|
|
622
623
|
// these are the project's constitutional documents rather than its handbook.
|
|
@@ -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/me.js
CHANGED
|
@@ -34,6 +34,7 @@ const db = require('../db');
|
|
|
34
34
|
// prefs-module-less instance so the slices simply render their unset/default shape.
|
|
35
35
|
const { validateOrRespond } = require('./_helpers');
|
|
36
36
|
const { readModes: readCraftModes, describeCraftMode } = require('../craft-mode'); // task 1002992
|
|
37
|
+
const { withStandingRules } = require('../agent-standing-rules'); // task 1005015
|
|
37
38
|
const { branding } = require('../../branding'); // task 2011: instance model-allocation default
|
|
38
39
|
// The onboarding state machine carved into modules/onboarding/ (BV1.R81 / ADR
|
|
39
40
|
// 0093 §1): the GET /me aggregator + PATCH /me/disciplines resolve the
|
|
@@ -565,7 +566,9 @@ module.exports = function buildMeRouter() {
|
|
|
565
566
|
// contract. The old §2 "The Prompter" owner-defaults, now per-builder. The
|
|
566
567
|
// session-start injection + /settings UI read this; describeInteraction is
|
|
567
568
|
// the SSOT (builder-settings port). null when the prefs module is off.
|
|
568
|
-
|
|
569
|
+
// task 1005015: the standing rules (human-task markers, slop detection) ride
|
|
570
|
+
// this contract because every Conductor, stale ones included, relays it.
|
|
571
|
+
interaction: withStandingRules(lawed ? lawed.interaction : (settings && settings.describeInteraction ? settings.describeInteraction(behaviorPrefs) : null)),
|
|
569
572
|
// task 1004707: the project's LAWS, read into every AI session. The
|
|
570
573
|
// Conductor hook relays `contract` verbatim beside the interaction profile
|
|
571
574
|
// (inject:false, and no block, when no law is in force). `laws` is the book
|
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.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');
|