@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.
- package/.bongos-core.json +51 -41
- package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +3 -0
- package/docs/adr/0364-repo-access-is-asked-at-sign-in-and-held-for-the-session.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +255 -255
- package/docs/copy-registry.json +288 -288
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +363 -362
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/hall-ui/public/deploy.js +3 -0
- package/modules/public-landing/public/account.html +2 -2
- package/modules/public-landing/public/account.probes.json +2 -2
- package/modules/public-landing/public/index.html +1 -1
- package/modules/public-landing/public/projects.html +22 -13
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/provision-core-upgrade.js +52 -20
- package/scripts/gds/upgrade-migrate.js +101 -4
- package/scripts/gds/upgrade-outcome.js +6 -0
- package/scripts/gds/upgrade.js +39 -44
- package/scripts/gds/wedge-remedy.js +10 -4
- package/src/bongos/db.js +7 -6
- package/src/bongos/routes/auth.js +17 -4
- package/src/module-api.js +1 -1
- package/tests/core_upgrade_runner.mjs +34 -10
- package/tests/github_token_session_life.mjs +110 -0
- package/tests/hub_account_page.mjs +2 -2
- package/tests/instance_db_migrated_on_code_move.mjs +20 -2
- package/tests/landing_page.mjs +2 -2
- package/tests/projects_hub_pre_uat.mjs +2 -1
- package/tests/upgrade.mjs +147 -0
- package/tests/upgrade_outcome.mjs +3 -0
- package/tests/wedge_remedy.mjs +36 -2
- package/tests/wizard_draft_resume.mjs +49 -0
- package/tests/wizard_intent_resume.mjs +4 -2
package/scripts/gds/upgrade.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
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 —
|
|
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
|
|
882
|
-
//
|
|
883
|
-
//
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
515
|
-
assert.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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.
|
|
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.
|
|
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&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&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', () => {
|
package/tests/landing_page.mjs
CHANGED
|
@@ -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
|
|
294
|
+
assert.match(body, /class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&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
|
|
546
|
+
assert.match(body, /<span class="whoSlot" id="who">\s*<a class="pill signin" href="\/api\/bongos\/auth\/web\/start\?return=\/&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&repos=1">sign in again<\/a> to keep watching/, 'the progress watch');
|
|
229
|
-
|
|
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'\) \+ '&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', () => {
|