@bongos/core 1.21.14 → 1.21.16

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 (36) hide show
  1. package/.bongos-core.json +51 -41
  2. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +3 -0
  3. package/docs/adr/0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md +30 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/copy-inventory.md +255 -255
  6. package/docs/copy-registry.json +288 -288
  7. package/docs/module-api-changelog.md +4 -0
  8. package/docs/page-readings.json +363 -362
  9. package/docs/recipes/upgrading-the-core.md +1 -0
  10. package/modules/hall-ui/public/deploy.js +3 -0
  11. package/modules/public-landing/public/account.html +2 -2
  12. package/modules/public-landing/public/account.probes.json +2 -2
  13. package/modules/public-landing/public/index.html +1 -1
  14. package/modules/public-landing/public/projects.html +22 -13
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/release-notes.json +12 -0
  18. package/scripts/gds/provision-core-upgrade.js +52 -20
  19. package/scripts/gds/upgrade-migrate.js +101 -4
  20. package/scripts/gds/upgrade-outcome.js +6 -0
  21. package/scripts/gds/upgrade.js +39 -44
  22. package/scripts/gds/wedge-remedy.js +10 -4
  23. package/src/bongos/db.js +7 -6
  24. package/src/bongos/routes/auth.js +17 -4
  25. package/src/module-api.js +1 -1
  26. package/tests/core_upgrade_runner.mjs +34 -10
  27. package/tests/github_token_session_life.mjs +110 -0
  28. package/tests/hub_account_page.mjs +2 -2
  29. package/tests/instance_db_migrated_on_code_move.mjs +20 -2
  30. package/tests/landing_page.mjs +2 -2
  31. package/tests/projects_hub_pre_uat.mjs +2 -1
  32. package/tests/upgrade.mjs +147 -0
  33. package/tests/upgrade_outcome.mjs +3 -0
  34. package/tests/wedge_remedy.mjs +36 -2
  35. package/tests/wizard_draft_resume.mjs +49 -0
  36. package/tests/wizard_intent_resume.mjs +4 -2
@@ -31,6 +31,9 @@ const { spawnSync } = require('node:child_process');
31
31
  const { arg, hasFlag } = require('./cli-lib');
32
32
  const { parseSemver, compareSemver, isStable, listAvailableVersions } = require('./update-channel');
33
33
  const { checkArtistGate } = require('./upgrade-artist-gate');
34
+ // The database side of a move: which command migrates, as which account, and whether that
35
+ // account can reach the database (tasks 1004274, 1002712, 1004512).
36
+ const { preflightDbIdentity, asAccount, ACCOUNT_RE } = require('./upgrade-migrate');
34
37
 
35
38
  const CORE_PKG = '@bongos/core';
36
39
  const ARTIFACT = 'bongos-core';
@@ -130,7 +133,14 @@ function restorePin(instanceDir, depString, fsImpl = fs) {
130
133
  // `ignore` is waived (it comes from — and is argued for in — claude-materialize.js materializeWillRewrite, task 1004122); every OTHER dirty path still halts the bump. -uall (task 1004188): a bare status collapses a NEW untracked dir to one '?? dir/' entry the file-path waiver never matches.
131
134
  function gitTreeClean(instanceDir, run = spawnSync, ignore = []) {
132
135
  const r = run('git', ['status', '--porcelain', '-uall'], { cwd: instanceDir, encoding: 'utf8' });
133
- if (!r || r.error || r.status !== 0) return { ok: false, reason: 'git status failed (not a git repo?)' };
136
+ // git's OWN words, not a guess at them (task 1004512). `not a git repo?` was the only
137
+ // explanation ever offered, and the failure that actually happens in the field is
138
+ // `detected dubious ownership` — a repo git can see perfectly well and refuses to read
139
+ // for the account asking. Swallowing the reason is what let that read as dirt.
140
+ if (!r || r.error || r.status !== 0) {
141
+ const said = String((r && (r.stderr || r.stdout)) || (r && r.error && r.error.message) || '').trim().split('\n').slice(0, 4).join('\n');
142
+ return { ok: false, reason: said || 'git status failed (not a git repo?)' };
143
+ }
134
144
  const skip = new Set(ignore), all = (r.stdout || '').split('\n').filter((l) => l.trim());
135
145
  const kept = all.filter((l) => !skip.has(l.slice(3).trim()));
136
146
  return { ok: !kept.length, dirty: kept.join('\n'), ignored: all.length - kept.length };
@@ -509,37 +519,12 @@ function npmInstall(instanceDir, run = spawnSync) {
509
519
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
510
520
  }
511
521
 
512
- // migrate.sh reaches Postgres over the local socket as the CURRENT OS USER (peer
513
- // auth) — so running an upgrade under `sudo` connects as `root`, which usually has
514
- // no Postgres role. The failure surfaces mid-run as a raw
515
- // `FATAL: role "root" does not exist`, AFTER the pin has already moved, which then
516
- // triggers the whole auto-rollback dance for what is really an operator mistake.
517
- // This names it up front instead (task 1002712).
518
- function preflightDbIdentity({ instanceDir, db }, run = spawnSync) {
519
- const r = run('psql', ['-tAc', 'select 1'], {
520
- cwd: instanceDir, encoding: 'utf8',
521
- env: { ...process.env, ...(db ? { PGDATABASE: db } : {}) },
522
- });
523
- if (!r || r.error) return { ok: true, skipped: 'psql not on PATH — leaving it to migrate' };
524
- if (r.status === 0) return { ok: true };
525
-
526
- const out = `${r.stderr || ''}${r.stdout || ''}`;
527
- const m = out.match(/role "([^"]+)" does not exist/i);
528
- if (!m) return { ok: true, skipped: 'psql failed for another reason — leaving it to migrate' };
529
- return {
530
- ok: false,
531
- role: m[1],
532
- reason: `Postgres has no role "${m[1]}" — migrate would fail after the pin moved.\n`
533
- + ` migrate.sh connects over the local socket as the CURRENT OS USER. Running the\n`
534
- + ` upgrade under sudo connects as root, which is almost never a Postgres role.\n`
535
- + ` Re-run as the user that owns the instance (e.g. sudo -u <owner> …), passing the\n`
536
- + ` npm token through if the core comes from the registry.`,
537
- };
538
- }
539
-
540
- function runMigrate(instanceDir, run = spawnSync) {
522
+ function runMigrate(instanceDir, run = spawnSync, account = null) {
541
523
  const { cmd, args, env } = require('./upgrade-migrate').migratePlan(instanceDir); // its own migrate script, else the core's migrate.sh (task 1004274)
542
- const r = run(cmd, args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
524
+ // `account`: the project's own unix+Postgres identity when the move itself is running as
525
+ // the account that owns the CHECKOUT instead (--migrate-as; upgrade-migrate.js, task 1004512).
526
+ const q = asAccount(account, cmd, args, env ? { INIT_CWD: instanceDir } : {});
527
+ const r = run(q.cmd, q.args, { cwd: instanceDir, stdio: 'inherit', ...(env ? { env } : {}) });
543
528
  return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
544
529
  }
545
530
 
@@ -787,7 +772,7 @@ function ledgerInsert({ fromVersion, toVersion, sourceCommit, note, requestedByB
787
772
  // Skips silently only when NEITHER is present (a local rehearsal with no DB pointed at) — the bump's
788
773
  // verification never depends on the ledger. An attempted-but-failed insert returns skipped:false so
789
774
  // the caller can surface it (the manual-INSERT footgun from 1976), never lose it.
790
- async function recordLedger({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source }, deps = {}) {
775
+ async function recordLedger({ fromVersion, toVersion, sourceCommit, note, requestedByBuilderId, source, account = null }, deps = {}) {
791
776
  const url = deps.databaseUrl !== undefined ? deps.databaseUrl : process.env.DATABASE_URL;
792
777
  const database = deps.pgDatabase !== undefined ? deps.pgDatabase : process.env.PGDATABASE;
793
778
  if (!url && !database) return { ok: false, skipped: true, reason: 'no DATABASE_URL or PGDATABASE' };
@@ -828,12 +813,11 @@ async function recordLedger({ fromVersion, toVersion, sourceCommit, note, reques
828
813
  const sql = `${stmt.sql.slice(0, stmt.sql.indexOf(' VALUES '))}\n VALUES (${stmt.cols.map(psqlValue).join(', ')});\n`;
829
814
  const sets = [];
830
815
  for (let i = 0; i < stmt.cols.length; i++) sets.push('--set', `${stmt.cols[i]}=${stmt.vals[i] == null ? '' : stmt.vals[i]}`);
831
- const r = run('psql', [
832
- '-d', database,
833
- '-v', 'ON_ERROR_STOP=1',
834
- '-q', '--no-psqlrc',
835
- ...sets,
836
- ], { encoding: 'utf8', input: sql });
816
+ // Through the project's own account when the move is not running as it (task 1004512):
817
+ // this psql peer-auths exactly as migrate does, so it must be the same identity or the
818
+ // ledger row silently stops being written on every own-account project.
819
+ const q = asAccount(account, 'psql', ['-d', database, '-v', 'ON_ERROR_STOP=1', '-q', '--no-psqlrc', ...sets]);
820
+ const r = run(q.cmd, q.args, { encoding: 'utf8', input: sql });
837
821
  if (!r || r.error) {
838
822
  const enoent = !!(r && r.error && r.error.code === 'ENOENT');
839
823
  return { ok: false, skipped: enoent, reason: enoent ? 'psql not found' : (r && r.error ? r.error.message : 'psql spawn failed') };
@@ -1065,7 +1049,7 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
1065
1049
  // 5. record the reversal in the ledger via the always-present `note` column (no schema
1066
1050
  // dependency — so it records even when a FAILED migrate means a newer column wouldn't exist).
1067
1051
  const note = `rolled_back: ${reason} (attempted ${fromVersion || '(none)'} → ${toVersion}, restored ${snapshot.prevVersion || 'previous'})`;
1068
- const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1052
+ const ledger = await recordLedger({ fromVersion, toVersion, sourceCommit: opts.sourceCommit, note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource, account: opts.migrateAs || null }, deps);
1069
1053
  if (ledger.ok) log(' ✓ recorded rollback in core_upgrades ledger');
1070
1054
  else if (ledger.skipped) log(` • rollback ledger skipped (${ledger.reason})`);
1071
1055
  else err(` ! rollback ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1094,6 +1078,9 @@ async function runUpgrade(opts, deps = {}) {
1094
1078
  // failure AFTER that point (install / migrate / health) can revert. null until then = nothing to undo.
1095
1079
  let snapshot = null;
1096
1080
  if (!targetVersion) return { ok: false, error: 'missing --to <version> (the target core version)' };
1081
+ // Checked once, here, rather than at each of the three places it is spent: an account
1082
+ // name that reaches `sudo` is refused outright, never quoted into shape (task 1004512).
1083
+ if (opts.migrateAs && !ACCOUNT_RE.test(opts.migrateAs)) return { ok: false, error: `--migrate-as ${JSON.stringify(opts.migrateAs)} is not a unix account name` };
1097
1084
 
1098
1085
  const pinnedVersion = readPinnedCoreVersion(instanceDir, fsImpl);
1099
1086
  const fromVersion = readInstalledCoreVersion(instanceDir, fsImpl) || pinnedVersion;
@@ -1118,7 +1105,13 @@ async function runUpgrade(opts, deps = {}) {
1118
1105
  // Step 5's own output is not dirt (task 1004122) — but only when step 5 is going to run.
1119
1106
  const preview = opts.skipMaterialize ? () => [] : (deps.willRewrite || require('./claude-materialize').materializeWillRewrite);
1120
1107
  const tree = gitTreeClean(instanceDir, run, preview({ instanceDir, materialize }, err));
1121
- if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || tree.reason || ''}` };
1108
+ // TWO DIFFERENT FAILURES, TWO DIFFERENT SENTENCES (task 1004512). git ANSWERING with a
1109
+ // list of dirty paths is a dirty tree, which the self-heal chain knows how to fix. git
1110
+ // REFUSING to answer is not: committing anything would be committing a guess. They used
1111
+ // to share one message, so a `dubious ownership` refusal was classified dirty_tree, the
1112
+ // runner committed a tree that was never dirty, and the retry failed identically.
1113
+ if (!tree.ok && tree.reason) return { ok: false, error: `git could not read the working tree in ${instanceDir} — the clean-tree pre-flight never ran, so this is NOT a report of uncommitted work.\n${tree.reason}` };
1114
+ if (!tree.ok) return { ok: false, error: `working tree not clean — commit/stash first, or pass --force.\n${tree.dirty || ''}` };
1122
1115
  if (tree.ignored) log(` • pre-flight: ignored ${tree.ignored} dirty path(s) this upgrade rewrites from the core (.claude/ materialization)${opts.commitPin ? ' — they are committed with the pin' : ' — pass --commit-pin to commit them'}.`);
1123
1116
  }
1124
1117
  const mods = preflightModules({ instanceDir, targetVersion }, deps);
@@ -1139,10 +1132,10 @@ async function runUpgrade(opts, deps = {}) {
1139
1132
  + ' Checked BEFORE the pin moves: migrate runs after the install, so a name that\n'
1140
1133
  + ' cannot be resolved there costs a full rollback to discover (task 1002755).' };
1141
1134
  }
1142
- const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db }, run);
1135
+ const dbId = (deps.preflightDbIdentity || preflightDbIdentity)({ instanceDir, db, account: opts.migrateAs || null }, run);
1143
1136
  if (!dbId.ok) return { ok: false, error: dbId.reason };
1144
1137
  if (dbId.skipped) log(` • db identity pre-check${db ? ` (${db})` : ''}: ${dbId.skipped}`);
1145
- else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as the current user`);
1138
+ else log(` ✓ pre-flight: database${db ? ` "${db}"` : ''} reachable as ${opts.migrateAs || 'the current user'}`);
1146
1139
  const { note: migrateNote } = require('./upgrade-migrate').migratePlan(instanceDir); if (migrateNote) log(` ✓ pre-flight: ${migrateNote}`);
1147
1140
  }
1148
1141
 
@@ -1231,7 +1224,7 @@ async function runUpgrade(opts, deps = {}) {
1231
1224
 
1232
1225
  // 4. migrate (additive core_* migrations).
1233
1226
  if (!opts.skipMigrate) {
1234
- const mig = runMigrate(instanceDir, run);
1227
+ const mig = runMigrate(instanceDir, run, opts.migrateAs || null);
1235
1228
  if (!mig.ok) {
1236
1229
  const error = `migrate failed (${mig.reason || 'exit ' + mig.code})`;
1237
1230
  if (rollbackOnFailure && snapshot) {
@@ -1366,7 +1359,7 @@ async function runUpgrade(opts, deps = {}) {
1366
1359
  return { ok: false, error: `${why} — rolled back to ${snapshot.prevVersion || 'the previous core'}`, fromVersion, toVersion: targetVersion, versionOk, healthOk, servedOk, servedVersion, rolledBack: true, rollback: rb };
1367
1360
  }
1368
1361
 
1369
- const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource }, deps);
1362
+ const ledger = await recordLedger({ fromVersion, toVersion: targetVersion, sourceCommit: opts.sourceCommit, note: isDowngrade ? `downgrade (--allow-downgrade)${opts.note ? `: ${opts.note}` : ''}` : opts.note, requestedByBuilderId: opts.requestedBy, source: opts.upgradeSource, account: opts.migrateAs || null }, deps);
1370
1363
  if (ledger.ok) log(' ✓ recorded in core_upgrades ledger');
1371
1364
  else if (ledger.skipped) log(` • ledger skipped (${ledger.reason})`);
1372
1365
  else err(` ! ledger insert failed (${ledger.reason}) — record it manually`);
@@ -1422,6 +1415,7 @@ function parseArgs(argv) {
1422
1415
  requestedBy: arg('--requested-by', argv),
1423
1416
  upgradeSource: arg('--upgrade-source', argv),
1424
1417
  pinPath: arg('--pin-path', argv),
1418
+ migrateAs: arg('--migrate-as', argv), // task 1004512: the project's own unix+Postgres identity, when the move itself runs as the account that owns the checkout
1425
1419
  registry: hasFlag('--registry', argv), // ADR 0134: pull @bongos/core@<--to> from the npm registry instead of vendoring
1426
1420
  commitPin: hasFlag('--commit-pin', argv), // task 1002712: commit + push package.json/-lock so a pull-deploy reset cannot revert the bump
1427
1421
  pinNoPush: hasFlag('--pin-no-push', argv), // task 1003521 co-tenant mode: commit the pin but leave the customer's own remote alone
@@ -1458,6 +1452,7 @@ async function main(argv = process.argv.slice(2)) {
1458
1452
  ' --version-url <url> read the RUNNING process\'s coreVersion back after restart and require it to equal --to (default: /version on the --health-url origin). A health check alone cannot tell a new core from an old one that never went down.',
1459
1453
  ' --no-health-check explicitly skip the post-restart health confirmation (default: the check is ON — a bump that restarts the service must confirm it serves); also skips the served-version read-back',
1460
1454
  ' --skip-migrate do not migrate (the default runs the instance\'s `migrate` script, else the core\'s scripts/migrate.sh)',
1455
+ ' --migrate-as <user> run the database steps (identity pre-check, migrate, ledger) as this unix account via `sudo -n -u`. For a project whose checkout is owned by the operator account but whose database is owned by its OWN account: the move needs the first to write files, the migration needs the second to connect. Omit it and everything runs as the current user, as before',
1461
1456
  ' --skip-materialize do not refresh .claude/',
1462
1457
  ' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
1463
1458
  ' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
@@ -34,7 +34,7 @@
34
34
 
35
35
  const { CONFIG } = require('./provision-config.js');
36
36
  const { dbName, selfInstanceRoot, standaloneRoot } = require('./provision-repo.js');
37
- const { migratePlan, CORE_MIGRATE_SCRIPT } = require('./upgrade-migrate.js');
37
+ const { migratePlan, CORE_MIGRATE_SCRIPT, ACCOUNT_RE } = require('./upgrade-migrate.js');
38
38
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
39
39
  const outcome = require('./upgrade-outcome.js');
40
40
  const { DEFAULT_RETRY_DELAY_MS } = require('./intent-retry.js');
@@ -48,7 +48,10 @@ const REMEDY_EVENT = 'core-upgrade-remedy';
48
48
  // purpose: anything outside it is refused, never quoted.
49
49
  const SHELL_SAFE_RE = /^[A-Za-z0-9._/-]{1,200}$/;
50
50
  const DB_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/;
51
- const ACCOUNT_RE = /^[a-z_][a-z0-9_-]{0,31}$/;
51
+ // ACCOUNT_RE is imported, not re-declared: this file and upgrade-migrate.js check the same
52
+ // value for the same reason (it reaches `sudo -u`), and two copies of a security predicate
53
+ // drift one edit at a time. provision-repo.js keeps its own UNIX_ACCOUNT_RE deliberately —
54
+ // importing from here would point the provisioner at the upgrade CLI, the wrong direction.
52
55
 
53
56
  /**
54
57
  * The fix for one known snag, as a command to run on the box. PURE.
@@ -105,9 +108,12 @@ function planRemedy(inst, reason, o = {}) {
105
108
  return { reason, cwd: dir, env: { PGDATABASE: db, ...(initCwd ? { INIT_CWD: dir } : {}) }, cmd: run, describe: `migrated ${db} to match the code it is running` };
106
109
  }
107
110
 
108
- // The account the project runs as, probed the way the move itself probes (task 1004128).
111
+ // The account the project's DATABASE belongs to, probed the way the move itself probes
112
+ // (task 1004128). `migrateAs`, not `runAs`: since task 1004512 those are two different
113
+ // answers, and this remedy is a migration — it needs the Postgres login role, which the
114
+ // account that owns the checkout is not.
109
115
  function runAsFor(inst, deps) {
110
- try { return require('./provision-core-upgrade.js').resolveRunAs(inst, deps).runAs; } catch { return null; }
116
+ try { return require('./provision-core-upgrade.js').resolveRunAs(inst, deps).migrateAs; } catch { return null; }
111
117
  }
112
118
 
113
119
  /**
package/src/bongos/db.js CHANGED
@@ -253,16 +253,17 @@ async function getBuilderRoleChoices(builderIds) {
253
253
 
254
254
  // ---- GitHub OAuth repo token, encrypted at rest (core_192 / task 1002099) ----
255
255
  //
256
- // The onboarding wizard (public-landing/create.html) opts into `public_repo` at
257
- // sign-in so it can list + create the user's repos. That user-to-server GitHub
258
- // token is held here BRIEFLY: encrypted with secret-box (master key in server env
259
- // only — the plaintext never touches the DB) and given a short TTL (one wizard
260
- // sitting), read back by the repo picker/creator, then deleted on use. It is the
256
+ // The hub's sign-in doors opt into `repo` at sign-in so the create wizard can list +
257
+ // create the user's repos. That user-to-server GitHub token is held here: encrypted
258
+ // with secret-box (master key in server env only — the plaintext never touches the
259
+ // DB) for as long as the web session it arrived with (the callback passes the
260
+ // session's remaining life as ttlMs; task 1004549, ADR 0364), and deleted on web
261
+ // sign-out, on offboarding, or when GitHub says it was revoked. It is the
261
262
  // auth system's OWN handshake token (distinct from a feature's third-party key
262
263
  // like the art module's Gemini key, ADR 0102), so it lives in core auth. secret-box
263
264
  // is the gate: with no master key configured we NEVER store a plaintext token —
264
265
  // capture no-ops and the wizard degrades to manual repo entry (ADR 0143).
265
- const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — one wizard sitting.
266
+ const GITHUB_TOKEN_TTL_MS = 60 * 60 * 1000; // 1h — the fallback when no session expiry is known.
266
267
 
267
268
  // Store (or replace) the builder's GitHub repo token. `token` is the raw
268
269
  // user-to-server access token; it is encrypted here so the plaintext never touches
@@ -878,13 +878,18 @@ module.exports = function buildAuthRouter() {
878
878
  // every older name and the legacy apex-scoped copy, so a browser arriving
879
879
  // with either leaves holding only the host-only cookie.
880
880
  setSessionCookie(req, res, token, expiresAt);
881
- // Persist the wizard's repo token (task 1002099 / ADR 0143): encrypted at rest,
882
- // short-TTL, keyed to the builder — the repo picker/creator reads it back over
883
- // the next few minutes. Gated on secret-box being configured (upsertGithubToken
881
+ // Persist the repo token (task 1002099 / ADR 0143): encrypted at rest, keyed to
882
+ // the builder. It lives exactly as long as the session it rode in on (task
883
+ // 1004549, ADR 0364) — a builder who stays signed in is never asked again, and
884
+ // signing out drops it. Gated on secret-box being configured (upsertGithubToken
884
885
  // no-ops when it isn't). Best-effort: a capture failure must NEVER break sign-in.
885
886
  if (wantsRepos && githubToken) {
886
887
  try {
887
- await db.upsertGithubToken({ builderId: builder.id, token: githubToken, scope: githubScope });
888
+ const sessionLeft = expiresAt ? new Date(expiresAt).getTime() - Date.now() : 0;
889
+ await db.upsertGithubToken({
890
+ builderId: builder.id, token: githubToken, scope: githubScope,
891
+ ...(sessionLeft > 0 ? { ttlMs: sessionLeft } : {}),
892
+ });
888
893
  } catch (e) {
889
894
  log.error('[gds] web/callback: repo-token capture failed (non-blocking):', e.message);
890
895
  }
@@ -1157,6 +1162,14 @@ module.exports = function buildAuthRouter() {
1157
1162
  const token = (req.headers['authorization'] || '').replace(/^Bearer\s+/, '')
1158
1163
  || sessionCookieToken(req);
1159
1164
  if (token) await db.revokeSession(token);
1165
+ // A WEB sign-out also drops the stored repo token (task 1004549, ADR 0364): it is
1166
+ // held for as long as the builder stays signed in, and no longer. A CLI logout
1167
+ // (bearer header) leaves it — the browser session that granted it is still live.
1168
+ if (!req.headers['authorization'] && sessionCookieToken(req)) {
1169
+ await db.deleteGithubToken({ builderId: req.builder.id }).catch((e) => {
1170
+ log.error('[gds] auth/logout: repo-token delete failed (non-blocking):', e.message);
1171
+ });
1172
+ }
1160
1173
  // Clear every session name, host-only AND the legacy apex-scoped form — a
1161
1174
  // clearCookie only matches the cookie whose domain attribute it names, and a
1162
1175
  // sign-out that left one behind would re-authenticate the next request.
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.14'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.16'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -507,12 +507,20 @@ await t('the control plane upgrades as the operator account, never as root', asy
507
507
  assert.doesNotMatch(cmd, /sudo node /, 'a bare `sudo node` is the bug — it lands on root');
508
508
  });
509
509
 
510
- await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
510
+ await t('a tenant MOVES as the account that owns its checkout and MIGRATES as its own (task 1004512)', async () => {
511
+ // The move writes: the pin, node_modules, .claude/, the pin commit — and before any of
512
+ // that, `git status`. Those belong to the app user, which is who scaffolded the
513
+ // checkout; the instance account only has group READ on it, so running the move as that
514
+ // account made git refuse with "detected dubious ownership" and the clean-tree pre-flight
515
+ // report dirt that did not exist. The DATABASE is the opposite: it is owned by the
516
+ // instance's own Postgres login role with CONNECT revoked from PUBLIC, so only that
517
+ // account can migrate it. Hence one move, two accounts.
511
518
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
512
519
  await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
513
520
  const cmd = upgradeCall(d);
514
- assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
515
- assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
521
+ assert.match(cmd, /sudo -u appsvc /, 'the move runs as the account that owns the checkout');
522
+ assert.match(cmd, /--migrate-as bongos-test4\b/, 'and hands the database steps to the instance account');
523
+ assert.doesNotMatch(cmd, /sudo -u bongos-test4 /, 'the instance account must not be asked to write a checkout it can only read');
516
524
  });
517
525
 
518
526
  await t('the database is NAMED, not discovered — an adopted repo has no core at its root to ask', async () => {
@@ -568,7 +576,9 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
568
576
  const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
569
577
  const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
570
578
  const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
571
- for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
579
+ // --migrate-as is SHARED, not tail: the dry run's own database pre-check connects as that
580
+ // account too, so a preview that omitted it would test a different identity than the move.
581
+ for (const shared of ['sudo -u appsvc ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry', '--migrate-as bongos-test4']) {
572
582
  assert.ok(move.cmd.includes(shared), `move carries ${shared}`);
573
583
  assert.ok(peek.cmd.includes(shared), `preview carries the same ${shared}`);
574
584
  }
@@ -746,12 +756,15 @@ function acctDeps({ accountExists = true, dryRunStdout = '', dryRunStderr = '',
746
756
  return d;
747
757
  }
748
758
 
749
- await t('a tenant WITH its own account runs as that account — the security model is kept', () => {
759
+ await t('a tenant WITH its own account MIGRATES as that account — the security model is kept', () => {
750
760
  // task 1003369: a distinct uid per instance is what stops a co-tenant reading a
751
- // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
761
+ // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it. Task
762
+ // 1004512 moved the file half of the work to the checkout's owner and left this half
763
+ // exactly where it was: the database is where the per-instance identity does its job.
752
764
  const d = acctDeps({ accountExists: true });
753
765
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
754
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
766
+ assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
767
+ { runAs: 'appsvc', migrateAs: 'bongos-test4', fellBack: false });
755
768
  });
756
769
 
757
770
  await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
@@ -762,6 +775,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
762
775
  const d = acctDeps({ accountExists: false });
763
776
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
764
777
  assert.equal(r.runAs, 'appsvc');
778
+ assert.equal(r.migrateAs, 'appsvc', 'both halves land on the app user — the shape that was already working');
765
779
  assert.equal(r.fellBack, true);
766
780
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
767
781
  });
@@ -769,7 +783,8 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
769
783
  await t('the control plane is never probed — it has no instance account by design', () => {
770
784
  const d = acctDeps({ accountExists: false });
771
785
  const r = R.resolveRunAs(inst({ hosting_shape: 'control-plane', slug: 'cloudbongos' }), d);
772
- assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'appsvc', fellBack: false });
786
+ assert.deepEqual({ runAs: r.runAs, migrateAs: r.migrateAs, fellBack: r.fellBack },
787
+ { runAs: 'appsvc', migrateAs: 'appsvc', fellBack: false });
773
788
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 0, 'no probe was spent');
774
789
  });
775
790
 
@@ -796,7 +811,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
796
811
  const d = makeDeps({ served: '1.19.762' });
797
812
  d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
798
813
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
799
- assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
814
+ assert.equal(r.migrateAs, 'bongos-test4', 'the intended account is kept');
800
815
  assert.equal(r.fellBack, false);
801
816
  assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
802
817
  });
@@ -813,7 +828,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
813
828
  const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
814
829
  assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
815
830
  assert.equal(asked.control.length, 1, 'the control-plane executor was');
816
- assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
831
+ assert.equal(r.migrateAs, 'bongos-test4', 'and its answer is the one that counts');
817
832
  });
818
833
 
819
834
  await t('a planning tick probes ONCE, not twice', async () => {
@@ -860,9 +875,18 @@ await t('a fallback is recorded on the verdict the owner reads', async () => {
860
875
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
861
876
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
862
877
  assert.equal(v.ran_as, 'appsvc');
878
+ assert.equal(v.migrated_as, 'appsvc', 'the verdict names BOTH accounts — since task 1004512 there are two');
863
879
  assert.match(v.account_note, /no bongos-test4 account yet/);
864
880
  });
865
881
 
882
+ await t('a project WITH its own account has its two accounts named separately (task 1004512)', async () => {
883
+ const d = acctDeps({ accountExists: true, dryRunStdout: ' ✓ pre-flight: ok\n' });
884
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
885
+ assert.equal(v.ran_as, 'appsvc', 'the files are written by the account that owns the checkout');
886
+ assert.equal(v.migrated_as, 'bongos-test4', 'the database is touched by the account that owns it');
887
+ assert.equal(v.account_note, null, 'and nothing is missing, so there is nothing to report');
888
+ });
889
+
866
890
  await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
867
891
  const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
868
892
  const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
@@ -0,0 +1,110 @@
1
+ // tests/github_token_session_life.mjs — the repo token lives as long as the sign-in (task 1004549).
2
+ //
3
+ // Owner decision 2026-10-02 (ADR 0364): repo access is asked once, at sign-in, and stays with
4
+ // the builder for as long as they stay signed in. It used to be held for one hour, so an owner
5
+ // still signed in was sent back to GitHub from the middle of the create wizard for a grant
6
+ // GitHub already held. Two halves, both over the REAL auth router:
7
+ // - the callback stores the token with the session's remaining life as its TTL;
8
+ // - a web sign-out deletes it; a CLI logout (bearer header) leaves it alone.
9
+ // Only the GitHub code exchange, the session lookup and the token store are stubbed.
10
+ //
11
+ // Run: node --test tests/github_token_session_life.mjs
12
+
13
+ import { test, before, after } from 'node:test';
14
+ import assert from 'node:assert/strict';
15
+ import http from 'node:http';
16
+ import { createRequire } from 'node:module';
17
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+
21
+ // RFC 2606 reserved — synthetic, never routable, no host brand in this file.
22
+ const APEX = 'bongos-test.example';
23
+ const brandDir = mkdtempSync(join(tmpdir(), 'cb-gh-token-life-'));
24
+ const brandFile = join(brandDir, 'branding.json');
25
+ writeFileSync(brandFile, JSON.stringify({
26
+ envPrefix: 'GHTOKLIFE_FIXTURE',
27
+ domains: { cookieDomain: `.${APEX}`, publicOrigin: `https://${APEX}`, oauthOrigin: `https://${APEX}` },
28
+ }));
29
+ process.env.BONGOS_BRANDING_FILE = brandFile;
30
+ process.env.GHTOKLIFE_FIXTURE_GITHUB_CLIENT_ID = 'fixture-client';
31
+ process.env.GHTOKLIFE_FIXTURE_GITHUB_CLIENT_SECRET = 'fixture-secret';
32
+ const require = createRequire(import.meta.url);
33
+ for (const p of ['BONGOS', ...require('../src/instance-config.js').LEGACY_ENV_PREFIXES]) delete process.env[`${p}_PUBLIC_ORIGIN`];
34
+ process.on('exit', () => { try { rmSync(brandDir, { recursive: true, force: true }); } catch { /* best-effort */ } });
35
+
36
+ const express = require('express');
37
+ const auth = require('../src/bongos/auth.js');
38
+ const db = require('../src/bongos/db');
39
+
40
+ const SESSION_MS = 30 * 24 * 60 * 60 * 1000;
41
+ const stored = [];
42
+ const deleted = [];
43
+ const real = {
44
+ completeWebFlow: auth.completeWebFlow, requireBuilder: auth.requireBuilder,
45
+ upsertGithubToken: db.upsertGithubToken, deleteGithubToken: db.deleteGithubToken, revokeSession: db.revokeSession,
46
+ };
47
+ auth.completeWebFlow = async () => ({
48
+ token: 'tok' + 'a'.repeat(40),
49
+ expiresAt: new Date(Date.now() + SESSION_MS),
50
+ builder: { id: 7 },
51
+ githubToken: 'gho_fixture',
52
+ githubScope: 'read:user,repo',
53
+ });
54
+ // The router binds requireBuilder when it is BUILT, so it is swapped before the require below.
55
+ auth.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_id: '1', github_login: 'probe' }; next(); };
56
+ db.upsertGithubToken = async (args) => { stored.push(args); return { stored: true }; };
57
+ db.deleteGithubToken = async (args) => { deleted.push(args); return { deleted: true }; };
58
+ db.revokeSession = async () => {};
59
+ const buildAuthRouter = require('../src/bongos/routes/auth.js');
60
+
61
+ let server, port;
62
+ before(async () => {
63
+ const app = express();
64
+ app.set('trust proxy', true);
65
+ app.use('/api/bongos', buildAuthRouter());
66
+ server = http.createServer(app);
67
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
68
+ port = server.address().port;
69
+ });
70
+ after(() => { server.close(); Object.assign(auth, { completeWebFlow: real.completeWebFlow, requireBuilder: real.requireBuilder }); Object.assign(db, { upsertGithubToken: real.upsertGithubToken, deleteGithubToken: real.deleteGithubToken, revokeSession: real.revokeSession }); });
71
+
72
+ function request(method, path, headers = {}) {
73
+ return new Promise((resolve, reject) => {
74
+ const req = http.request({ host: '127.0.0.1', port, path, method, headers: { host: APEX, 'x-forwarded-proto': 'https', ...headers } }, (res) => {
75
+ let body = '';
76
+ res.on('data', (c) => { body += c; });
77
+ res.on('end', () => resolve({ status: res.statusCode, setCookies: [].concat(res.headers['set-cookie'] || []), body }));
78
+ });
79
+ req.on('error', reject);
80
+ req.end();
81
+ });
82
+ }
83
+ const pairs = (setCookies) => setCookies.map((c) => c.split(';')[0]).join('; ');
84
+
85
+ test('a repo sign-in stores the token for the life of the session, not one hour', async () => {
86
+ stored.length = 0;
87
+ const start = await request('GET', '/api/bongos/auth/web/start?return=/projects&repos=1');
88
+ assert.equal(start.status, 302);
89
+ const state = (start.setCookies.find((c) => c.startsWith('gds_oauth_state=')) || '').split(';')[0].split('=')[1];
90
+ assert.ok(state, 'the start set a state cookie');
91
+ const cb = await request('GET', `/api/bongos/auth/web/callback?code=x&state=${state}`, { cookie: pairs(start.setCookies) });
92
+ assert.equal(cb.status, 302, cb.body);
93
+ assert.equal(stored.length, 1, 'the repo token was stored');
94
+ const ttl = stored[0].ttlMs;
95
+ assert.ok(ttl > SESSION_MS - 60_000 && ttl <= SESSION_MS, `the TTL follows the 30-day session (got ${ttl}ms)`);
96
+ });
97
+
98
+ test('a web sign-out deletes the repo token', async () => {
99
+ deleted.length = 0;
100
+ const out = await request('POST', '/api/bongos/auth/logout', { cookie: auth.SESSION_COOKIE_NAMES[0] + '=tok' + 'a'.repeat(40) });
101
+ assert.equal(out.status, 200, out.body);
102
+ assert.deepEqual(deleted, [{ builderId: 7 }]);
103
+ });
104
+
105
+ test('a CLI logout (bearer) leaves the browser session’s repo token alone', async () => {
106
+ deleted.length = 0;
107
+ const out = await request('POST', '/api/bongos/auth/logout', { authorization: 'Bearer cli' + 'b'.repeat(40) });
108
+ assert.equal(out.status, 200, out.body);
109
+ assert.deepEqual(deleted, []);
110
+ });
@@ -74,7 +74,7 @@ test('the script builds no markup from strings, and the controls are real markup
74
74
  });
75
75
 
76
76
  test('the door returns here, and the page calls only its own-scoped routes', () => {
77
- assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account"/, 'sign-in lands back on /account');
77
+ assert.match(PAGE, /href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1"/, 'sign-in lands back on /account, asking for repo access once (task 1004549)');
78
78
  const calls = [...SCRIPT.matchAll(/request\('(GET|PATCH|POST)', '([^']+)'/g)].map((m) => `${m[1]} ${m[2]}`);
79
79
  assert.deepEqual([...new Set(calls)].sort(), ['GET /me', 'GET /me/cross-project','PATCH /me/cross-project', 'PATCH /me/handle', 'PATCH /me/profile'].sort());
80
80
  assert.match(SCRIPT, /fetch\(API \+ '\/auth\/logout', \{ method: 'POST'/, 'sign out is the hub logout POST');
@@ -107,7 +107,7 @@ test('the bar is the hub’s own: the same markup as /projects, no header of its
107
107
  assert.ok(HUB.includes(piece), `projects.html no longer carries: ${piece}`);
108
108
  assert.ok(PAGE.includes(piece), `account.html does not carry the hub's: ${piece}`);
109
109
  }
110
- assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
110
+ assert.match(PAGE, /<a class="signinDoor" id="signinTop" hidden href="\/api\/bongos\/auth\/web\/start\?return=\/account&amp;repos=1">Sign in<\/a>/, 'signed out, the door in the chip’s slot returns here');
111
111
  assert.match(PAGE, /<a class="skip vh" href="#acct">/);
112
112
  assert.match(PAGE, /<main class="acct" id="acct">/, 'the skip link has its landmark');
113
113
  });
@@ -32,7 +32,7 @@ const tenant = { slug: 'charter', db_name: 'charter_db', hosting_shape: 'co-tena
32
32
 
33
33
  test('bongos upgrade migrates before it restarts, and only --skip-migrate opts out', () => {
34
34
  const src = fs.readFileSync(path.join(ROOT, 'scripts/gds/upgrade.js'), 'utf8');
35
- const migrate = src.indexOf('const mig = runMigrate(instanceDir, run);');
35
+ const migrate = src.indexOf('const mig = runMigrate(instanceDir, run, opts.migrateAs || null);');
36
36
  const restart = src.lastIndexOf('restartService({ service: opts.service, restartCmd: opts.restartCmd }');
37
37
  assert.ok(migrate > 0, 'the upgrade flow runs migrate');
38
38
  assert.ok(restart > migrate, 'the restart that makes new code live comes AFTER migrate');
@@ -42,14 +42,32 @@ test('bongos upgrade migrates before it restarts, and only --skip-migrate opts o
42
42
  // no scripts is migrated rather than rolled back. tests/upgrade_migrate.mjs exercises both.
43
43
  assert.match(src, /function runMigrate\([^)]*\) \{\n\s*const \{ cmd, args, env \} = require\('\.\/upgrade-migrate'\)\.migratePlan\(instanceDir\)/,
44
44
  'the migrate step spawns what migratePlan decides');
45
+ // …and as the account that OWNS the database, when the move itself runs as the one that
46
+ // owns the checkout (task 1004512). Those are two different accounts on every project
47
+ // provisioned since task 1003369, and the wrong one cannot connect at all: the project's
48
+ // database is owned by its own Postgres login role with CONNECT revoked from PUBLIC.
49
+ // Asserting the CALL SITE, not just the helper — the helper was never the gap.
50
+ assert.match(src, /const mig = runMigrate\(instanceDir, run, opts\.migrateAs \|\| null\);/,
51
+ 'the migrate step is handed --migrate-as, not left on whoever the move happens to be');
45
52
  assert.equal(`bash ${CORE_MIGRATE_SCRIPT}`, INSTANCE_MIGRATE_SCRIPT, 'migrate is the instance scaffold script, i.e. the core migrate.sh');
46
53
  });
47
54
 
48
55
  test('the control-plane runner targets the instance database, not its own', () => {
49
- const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc' });
56
+ const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'appsvc' });
50
57
  assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `));
51
58
  assert.match(cmd, /upgrade\.js/);
52
59
  assert.doesNotMatch(cmd, /--skip-migrate/, 'the Deploy door never skips the schema');
60
+ assert.doesNotMatch(cmd, /--migrate-as/, 'one account for both halves emits no flag — unchanged from before task 1004512');
61
+ });
62
+
63
+ test('a project with its OWN account is migrated as it, whoever the move runs as (task 1004512)', () => {
64
+ // Naming the right database is only half of reaching it; the other half is connecting as
65
+ // a role that may. The runner used to solve both by running the WHOLE move as the
66
+ // project's account — which can only READ its checkout, so the move died at `git status`
67
+ // and the schema moved nowhere. Splitting the two keeps the database leg where it was.
68
+ const { cmd } = upgradeInvocation(tenant, '1.19.940', { privileged: true, runAs: 'appsvc', migrateAs: 'bongos-charter' });
69
+ assert.match(cmd, new RegExp(`PGDATABASE=${dbName(tenant)} `), 'still the instance database');
70
+ assert.match(cmd, /--migrate-as bongos-charter\b/, 'and now also the account that owns it');
53
71
  });
54
72
 
55
73
  test('provisioning migrates the new instance database at stand-up', () => {
@@ -291,7 +291,7 @@ test('the page reads without JavaScript: the spine is markup, not script-built',
291
291
  assert.ok(body.includes(copy), `"${copy}" is only reachable with scripting on`);
292
292
  }
293
293
  // the sign-in door is markup; the script only UPGRADES it to the signed-in chip
294
- assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/">Sign in</);
294
+ assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&amp;repos=1">Sign in</);
295
295
  });
296
296
 
297
297
  // ── (4) the motion kill switch ─────────────────────────────────────────────
@@ -543,7 +543,7 @@ test('the bar is markup: the mark, the Projects trigger, the sign-in door — an
543
543
  assert.match(body, /<span class="mark-word">\{\{productName\}\}<\/span>/, 'the product name as plain type, folded away under 900');
544
544
  assert.match(body, /<button class="trig" type="button" id="projTrig" data-menu="projects" aria-expanded="false" aria-controls="menuProjects">Projects<svg class="cv"/);
545
545
  assert.doesNotMatch(body, /modeBtn|id="mode"|class="moon"|class="sun"/, 'the mode glyph left the bar — the apex is dark only (task 1003336)');
546
- assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/">Sign in<\/a>\s*<\/span>/, 'the door stands in the identity slot until the session resolves');
546
+ assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&amp;repos=1">Sign in<\/a>\s*<\/span>/, 'the door stands in the identity slot until the session resolves');
547
547
  // what left with the pill bar
548
548
  assert.doesNotMatch(body, /class="pill nav-pill"|class="right-pills"|class="pill mark-pill"|data-nav="home"/, 'no nav pill, no right pills, no Home');
549
549
  assert.doesNotMatch(body.slice(body.indexOf('<header'), body.indexOf('</header>')), /data-link-hall/, 'the bar carries no hall link — the footer does');
@@ -226,7 +226,8 @@ test('the wizard’s 401 recoveries keep the repo scope they already granted', (
226
226
  assert.equal(plain, 0, 'no wizard redirect drops the scope');
227
227
  assert.ok((SCRIPT.match(/\/auth\/web\/start\?return=\/projects%3Fview%3Dnew&repos=1'\); throw new Error\('signing in'\)/g) || []).length >= 2, 'create + manifest');
228
228
  assert.match(SCRIPT, /projects%3Fview%3Dnew&amp;repos=1">sign in again<\/a> to keep watching/, 'the progress watch');
229
- assert.match(fn('signInAgain'), /currentView === 'new' \? '\/projects%3Fview%3Dnew&amp;repos=1'/, 'signInAgain from the wizard');
229
+ // every hub sign-in door asks for repo access since task 1004549, so signInAgain does too
230
+ assert.match(fn('signInAgain'), /\(currentView === 'new' \? '\/projects%3Fview%3Dnew' : '\/projects'\) \+ '&amp;repos=1'/, 'signInAgain from the wizard');
230
231
  });
231
232
 
232
233
  test('a resumed draft past the code row still probes repo access before Create', () => {