@bongos/core 1.18.0 → 1.18.3
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 +132 -322
- package/README.md +1 -1
- package/config/branding.neutral.json +5 -1
- package/docs/adr/0142-project-context-is-prose-only.md +41 -0
- package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
- package/docs/adr/README.md +5 -2
- package/docs/api/openapi.json +1789 -450
- package/docs/api-reference.md +52 -5
- package/docs/file-map.md +7 -7
- package/docs/recipes/packaging-the-core.md +13 -0
- package/docs/recipes/upgrading-the-core.md +5 -2
- package/migrations/039_newcomer_friendly_column.sql +5 -0
- package/migrations/102_remove_box_first_provision_approval.sql +4 -0
- package/migrations/core_192_github_oauth_tokens.sql +33 -0
- package/modules/hall-ui/public/my-projects.html +78 -0
- package/modules/hall-ui/public/my-projects.js +142 -0
- package/modules/hall-ui/public/scouting.html +45 -0
- package/modules/hall-ui/public/scouting.js +73 -0
- package/modules/status-ui/public/explore.html +54 -0
- package/modules/status-ui/public/explore.js +67 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/create.html +211 -14
- package/scripts/gds/cli-lib.js +11 -5
- package/scripts/gds/lint-migrations.js +102 -0
- package/scripts/gds/login.js +42 -10
- package/scripts/gds/mirror-redact.js +5 -0
- package/scripts/gds/package-core.js +263 -4
- package/scripts/gds/provision.js +145 -7
- package/scripts/gds/publish-manifest.js +15 -0
- package/scripts/gds/run-unit-tests.js +38 -14
- package/scripts/gds/setup.js +41 -0
- package/scripts/gds/ship.js +115 -31
- package/scripts/gds/upgrade.js +147 -5
- package/scripts/migrate.sh +10 -0
- package/src/bongos/auth.js +362 -5
- package/src/bongos/db.js +66 -0
- package/src/bongos/github-repos.js +93 -0
- package/src/bongos/routes/auth.js +93 -11
- package/src/bongos/routes/github.js +90 -0
- package/src/bongos/routes/instance.js +15 -3
- package/src/bongos/routes.js +2 -0
- package/src/branding.js +17 -2
- package/src/instance-config.js +16 -0
- package/src/module-api.js +29 -1
- package/tests/branding.mjs +29 -23
- package/tests/github_repos_helpers.mjs +91 -0
- package/tests/github_token_store.mjs +121 -0
- package/tests/idp_device_exchange.mjs +105 -0
- package/tests/idp_federation.mjs +100 -0
- package/tests/migrations_forward_safe.mjs +86 -0
- package/tests/module_api.mjs +1 -1
- package/tests/package_core.mjs +246 -1
- package/tests/provision.mjs +157 -0
- package/tests/pruned_lockfile_integrity.mjs +79 -0
- package/tests/ship_ci_deploy.mjs +60 -0
- package/tests/upgrade.mjs +254 -5
- package/.gitignore +0 -91
- package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
- package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
- package/scripts/hall-preview/fixtures/access-requests.json +0 -20
- package/scripts/hall-preview/fixtures/achievements.json +0 -76
- package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
- package/scripts/hall-preview/fixtures/audit-log.json +0 -68
- package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
- package/scripts/hall-preview/fixtures/box__me.json +0 -5
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
- package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
- package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
- package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
- package/scripts/hall-preview/fixtures/goals.json +0 -502
- package/scripts/hall-preview/fixtures/goals__25.json +0 -96
- package/scripts/hall-preview/fixtures/goals__26.json +0 -67
- package/scripts/hall-preview/fixtures/goals__3.json +0 -103
- package/scripts/hall-preview/fixtures/goals__35.json +0 -49
- package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
- package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
- package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
- package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
- package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
- package/scripts/hall-preview/fixtures/inbox.json +0 -330
- package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
- package/scripts/hall-preview/fixtures/instance.json +0 -124
- package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
- package/scripts/hall-preview/fixtures/me.json +0 -476
- package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
- package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
- package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
- package/scripts/hall-preview/fixtures/me__render.json +0 -5
- package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
- package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
- package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
- package/scripts/hall-preview/fixtures/override-requests.json +0 -15
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
- package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
- package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
- package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
- package/scripts/hall-preview/fixtures/public__grades.json +0 -50
- package/scripts/hall-preview/fixtures/public__progress.json +0 -124
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
- package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
- package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
- package/scripts/hall-preview/fixtures/security__docs.json +0 -10
- package/scripts/hall-preview/fixtures/security__reports.json +0 -31
- package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
- package/scripts/hall-preview/fixtures/tasks.json +0 -2420
- package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
- package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
- package/scripts/hall-preview/fixtures/versions.json +0 -89
package/scripts/gds/upgrade.js
CHANGED
|
@@ -295,6 +295,91 @@ async function pollHealth(healthUrl, deps = {}) {
|
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// ---- integrity-pin verification (task 1002216 / audit H8, finding F1) --------
|
|
299
|
+
// The packager (package-core.js) computes tree_sha256 = canonicalTreeHash over the
|
|
300
|
+
// redacted core files (+ the two synthesized files) and records it, the per-file
|
|
301
|
+
// hashes, AND the exact tarball bytes' sha256 in a side-car manifest — stating a
|
|
302
|
+
// consumer "verifies an install by recomputing it from the unpacked tree." Until now
|
|
303
|
+
// nothing on the CONSUME side did: `bongos upgrade` compared only the version STRING,
|
|
304
|
+
// so a corrupted / truncated / swapped tarball whose package.json still read the target
|
|
305
|
+
// version installed silently — the R88-class failure this goal exists to prevent.
|
|
306
|
+
// The three functions below close that gap: recompute the pin over the INSTALLED tree
|
|
307
|
+
// and assert it equals the TRUSTED reference manifest.
|
|
308
|
+
|
|
309
|
+
// Read + parse a manifest JSON; null on any failure (missing / unparseable).
|
|
310
|
+
function readManifest(file, fsImpl = fs) {
|
|
311
|
+
try { return JSON.parse(fsImpl.readFileSync(file, 'utf8')); } catch { return null; }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Resolve the trusted reference pin for this bump:
|
|
315
|
+
// • VENDORED — the committed side-car `vendor/<artifact>-<v>.manifest.json` (carries
|
|
316
|
+
// tree_sha256 + files[] + the exact tarball.sha256). It lives OUTSIDE the tarball
|
|
317
|
+
// (version-controlled in the consumer repo), so it detects a swapped/corrupted
|
|
318
|
+
// tarball. This is the strong reference.
|
|
319
|
+
// • REGISTRY / no side-car — the inner manifest `<core>/.bongos-core.json` that ships
|
|
320
|
+
// INSIDE the package (tree_sha256 + files[], no tarball.*). Self-referential (a full
|
|
321
|
+
// swap regenerates it too), so it is a self-consistency check backing npm's own
|
|
322
|
+
// registry integrity rather than an independent one — flagged `strong:false`.
|
|
323
|
+
function resolveReferenceManifest({ instanceDir, coreDir, targetVersion }, fsImpl = fs) {
|
|
324
|
+
const sidecarPath = path.join(instanceDir, 'vendor', `${ARTIFACT}-${targetVersion}.manifest.json`);
|
|
325
|
+
const sidecar = readManifest(sidecarPath, fsImpl);
|
|
326
|
+
if (sidecar && sidecar.tree_sha256) {
|
|
327
|
+
const tgz = path.join(instanceDir, 'vendor', `${ARTIFACT}-${targetVersion}.tgz`);
|
|
328
|
+
return { manifest: sidecar, tgzPath: fsImpl.existsSync(tgz) ? tgz : null, source: 'vendored side-car manifest', strong: true };
|
|
329
|
+
}
|
|
330
|
+
const inner = readManifest(path.join(coreDir, '.bongos-core.json'), fsImpl);
|
|
331
|
+
if (inner && inner.tree_sha256) return { manifest: inner, tgzPath: null, source: 'installed inner manifest', strong: false };
|
|
332
|
+
return { manifest: null, tgzPath: null, source: null, strong: false };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Recompute the integrity pin over the installed core tree and compare to `referenceManifest`.
|
|
336
|
+
// Reuses the PACKAGER's exact hash functions (canonicalTreeHash / sha256hex) so the consume-side
|
|
337
|
+
// recompute can never drift from the produce-side pin. Returns { ran, ok, checks, problems };
|
|
338
|
+
// ran=false ⇒ the manifest carried no pin to check against.
|
|
339
|
+
function verifyInstalledPin({ coreDir, referenceManifest, tgzPath }, deps = {}) {
|
|
340
|
+
const fsImpl = deps.fs || fs;
|
|
341
|
+
const pc = deps.packageCore || require('./package-core');
|
|
342
|
+
const checks = [];
|
|
343
|
+
const problems = [];
|
|
344
|
+
if (!referenceManifest || !referenceManifest.tree_sha256) return { ran: false, ok: false, checks, problems };
|
|
345
|
+
|
|
346
|
+
// (a) exact tarball bytes (vendored path only) — the strongest single check: the bytes
|
|
347
|
+
// npm actually installs, hashed and compared to the externally-held pin.
|
|
348
|
+
const tb = referenceManifest.tarball;
|
|
349
|
+
if (tgzPath && tb && tb.sha256 && fsImpl.existsSync(tgzPath)) {
|
|
350
|
+
const got = pc.sha256hex(fsImpl.readFileSync(tgzPath));
|
|
351
|
+
const ok = got === tb.sha256;
|
|
352
|
+
checks.push({ name: 'tarball.sha256', ok });
|
|
353
|
+
if (!ok) problems.push(`vendored tarball bytes sha256 ${got.slice(0, 12)}… ≠ pinned ${String(tb.sha256).slice(0, 12)}…`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// (b) installed tree — recompute each pinned file's sha256 from the unpacked tree, rebuild
|
|
357
|
+
// the canonical tree hash (using the manifest's already-canonical modes), assert equality.
|
|
358
|
+
// Catches partial extraction, post-install corruption, or a registry package that diverged.
|
|
359
|
+
const files = Array.isArray(referenceManifest.files) ? referenceManifest.files : [];
|
|
360
|
+
if (files.length) {
|
|
361
|
+
const recomputed = [];
|
|
362
|
+
let missing = 0, changed = 0;
|
|
363
|
+
for (const f of files) {
|
|
364
|
+
const abs = path.join(coreDir, ...String(f.path).split('/')); // POSIX manifest path → native
|
|
365
|
+
let buf;
|
|
366
|
+
try { buf = fsImpl.readFileSync(abs); } catch { missing++; problems.push(`missing installed file: ${f.path}`); continue; }
|
|
367
|
+
const sha = pc.sha256hex(buf);
|
|
368
|
+
if (sha !== f.sha256) { changed++; problems.push(`content mismatch: ${f.path}`); }
|
|
369
|
+
recomputed.push({ path: f.path, mode: f.mode, sha256: sha });
|
|
370
|
+
}
|
|
371
|
+
const treeHash = pc.canonicalTreeHash(recomputed);
|
|
372
|
+
const ok = missing === 0 && changed === 0 && treeHash === referenceManifest.tree_sha256;
|
|
373
|
+
checks.push({ name: 'tree_sha256', ok, missing, changed, file_count: files.length });
|
|
374
|
+
if (!ok && missing === 0 && changed === 0) {
|
|
375
|
+
problems.push(`recomputed tree_sha256 ${treeHash.slice(0, 12)}… ≠ pinned ${String(referenceManifest.tree_sha256).slice(0, 12)}…`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const ran = checks.length > 0;
|
|
380
|
+
return { ran, ok: ran && problems.length === 0, checks, problems };
|
|
381
|
+
}
|
|
382
|
+
|
|
298
383
|
// Auto-rollback (task 2149): a bump failed AFTER the pin was moved — an install or migrate
|
|
299
384
|
// error, or a failed post-restart health check. Revert the code pin to the pre-upgrade
|
|
300
385
|
// snapshot, reinstall the previous core, restart, re-verify health, and record the reversal
|
|
@@ -390,6 +475,18 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
390
475
|
log(` ✓ pre-flight: ${mods.note}`);
|
|
391
476
|
if (!mods.ok) return { ok: false, error: `module pre-check failed: ${mods.blocking.map((b) => b.key).join(', ')} incompatible with core ${targetVersion}` };
|
|
392
477
|
|
|
478
|
+
// 1b. mandatory post-restart health check (audit H9 / task 1002222, finding F5). A bump that
|
|
479
|
+
// brings the new core live via a managed restart (--service / --restart-cmd) MUST confirm it
|
|
480
|
+
// actually serves — an upgrade that can't be health-checked can't be reported as a success.
|
|
481
|
+
// Enforced here in PRE-FLIGHT, before anything is mutated, so there is nothing to roll back:
|
|
482
|
+
// if this bump will restart the service and the check isn't explicitly opted out, a
|
|
483
|
+
// --health-url is required. (No managed restart ⇒ nothing is brought live here ⇒ the check
|
|
484
|
+
// does not apply; the operator restarts + verifies separately.)
|
|
485
|
+
const willRestart = !!(opts.service || opts.restartCmd);
|
|
486
|
+
if (!dryRun && opts.healthCheck !== false && willRestart && !opts.healthUrl) {
|
|
487
|
+
return { ok: false, error: 'post-restart health check is mandatory for a bump that restarts the service, but no --health-url was given.\n Pass --health-url <url> to confirm the new core serves after restart, or --no-health-check to explicitly skip the confirmation.' };
|
|
488
|
+
}
|
|
489
|
+
|
|
393
490
|
// 2. bump the pin. Two mutually exclusive core sources (ADR 0134, task 2090):
|
|
394
491
|
// REGISTRY (--registry): pin @bongos/core@<version> + ensure the env-fed .npmrc, so
|
|
395
492
|
// the `npm install` below pulls the PRIVATE package from the registry (needs NPM_TOKEN).
|
|
@@ -429,6 +526,38 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
429
526
|
}
|
|
430
527
|
log(' ✓ npm install');
|
|
431
528
|
|
|
529
|
+
// 3b. verify the integrity pin (audit H8 / task 1002216, finding F1). Recompute tree_sha256 over
|
|
530
|
+
// the freshly-installed core (+ the exact vendored tarball bytes) and assert it matches the
|
|
531
|
+
// TRUSTED reference manifest — the pin the packager produced but the consume side never
|
|
532
|
+
// re-checked. Runs BEFORE migrate so a corrupted core can never touch the DB. A MISMATCH is
|
|
533
|
+
// fatal → rollback (like an install/migrate failure). A MISSING reference (no side-car AND no
|
|
534
|
+
// inner manifest) can't be verified: a warning by default, fatal only with --require-pin.
|
|
535
|
+
if (!opts.skipPinVerify) {
|
|
536
|
+
const coreDir = path.join(instanceDir, 'node_modules', CORE_PKG);
|
|
537
|
+
const ref = (deps.resolveReferenceManifest || resolveReferenceManifest)({ instanceDir, coreDir, targetVersion }, fsImpl);
|
|
538
|
+
if (!ref.manifest) {
|
|
539
|
+
const msg = `integrity pin: no reference manifest (looked for vendor/${ARTIFACT}-${targetVersion}.manifest.json and ${CORE_PKG}/.bongos-core.json) — cannot verify this install`;
|
|
540
|
+
if (opts.requirePin) {
|
|
541
|
+
const error = `${msg} (--require-pin)`;
|
|
542
|
+
if (rollbackOnFailure && snapshot) { const rb = await doRollback('integrity pin unverifiable (--require-pin)'); return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb }; }
|
|
543
|
+
return { ok: false, error, fromVersion };
|
|
544
|
+
}
|
|
545
|
+
err(` ! ${msg} — pass --require-pin to treat this as fatal.`);
|
|
546
|
+
} else {
|
|
547
|
+
const pin = (deps.verifyInstalledPin || verifyInstalledPin)({ coreDir, referenceManifest: ref.manifest, tgzPath: ref.tgzPath }, deps);
|
|
548
|
+
if (!pin.ok) {
|
|
549
|
+
const detail = pin.problems.slice(0, 6).join('; ') + (pin.problems.length > 6 ? `; +${pin.problems.length - 6} more` : '');
|
|
550
|
+
const error = `integrity pin MISMATCH (${ref.source}) — installed core does not match its pin: ${detail}`;
|
|
551
|
+
if (rollbackOnFailure && snapshot) {
|
|
552
|
+
const rb = await doRollback('integrity pin mismatch');
|
|
553
|
+
return { ok: false, error, fromVersion, toVersion: targetVersion, rolledBack: true, rollback: rb };
|
|
554
|
+
}
|
|
555
|
+
return { ok: false, error, fromVersion };
|
|
556
|
+
}
|
|
557
|
+
log(` ✓ integrity pin verified (${pin.checks.map((c) => c.name).join(' + ')}) against the ${ref.source}${ref.strong ? '' : ' — self-consistency check; npm registry integrity is the primary guard'}`);
|
|
558
|
+
}
|
|
559
|
+
} else log(' • integrity-pin verify skipped (--skip-pin-verify)');
|
|
560
|
+
|
|
432
561
|
// 4. migrate (additive core_* migrations).
|
|
433
562
|
if (!opts.skipMigrate) {
|
|
434
563
|
const mig = runMigrate(instanceDir, run);
|
|
@@ -472,11 +601,17 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
472
601
|
log(` ${versionOk ? '✓' : '!'} installed core version: ${installed || '(unknown)'}${versionOk ? '' : ` (expected ${targetVersion})`}`);
|
|
473
602
|
|
|
474
603
|
let healthOk = null;
|
|
475
|
-
if (opts.
|
|
604
|
+
if (opts.healthCheck === false) {
|
|
605
|
+
log(' • post-restart health check skipped (--no-health-check) — success is NOT confirmed against a live endpoint.');
|
|
606
|
+
} else if (opts.healthUrl) {
|
|
476
607
|
const h = await pollHealth(opts.healthUrl, deps);
|
|
477
608
|
healthOk = h.ok;
|
|
478
609
|
if (h.ok) log(` ✓ health ${opts.healthUrl} → ${h.status}${h.attempts > 1 ? ` (ready after ${h.attempts} tries)` : ''}`);
|
|
479
610
|
else err(` ! health ${opts.healthUrl} not ready after ${h.attempts} tr${h.attempts === 1 ? 'y' : 'ies'}: ${h.error || 'status ' + h.status}`);
|
|
611
|
+
} else {
|
|
612
|
+
// Reachable only when NO managed restart happened (pre-flight requires a URL when one will):
|
|
613
|
+
// the new core is installed on disk, but nothing was brought live here to health-check.
|
|
614
|
+
log(' • no managed restart + no --health-url — new core installed but not confirmed serving; restart the instance + health-check it to complete the bump.');
|
|
480
615
|
}
|
|
481
616
|
|
|
482
617
|
// A failed post-restart health check means the new core is serving broken — auto-revert (task
|
|
@@ -514,6 +649,9 @@ function parseArgs(argv) {
|
|
|
514
649
|
skipMigrate: hasFlag('--skip-migrate', argv),
|
|
515
650
|
skipMaterialize: hasFlag('--skip-materialize', argv),
|
|
516
651
|
skipRegenDocs: hasFlag('--skip-regen-docs', argv),
|
|
652
|
+
skipPinVerify: hasFlag('--skip-pin-verify', argv), // task 1002216: opt out of the post-install integrity-pin check
|
|
653
|
+
requirePin: hasFlag('--require-pin', argv), // task 1002216: a MISSING reference manifest becomes fatal (not just a warning)
|
|
654
|
+
healthCheck: !hasFlag('--no-health-check', argv), // task 1002222: post-restart health check is ON by default (finding F5/H9)
|
|
517
655
|
rollbackOnFailure: !hasFlag('--no-rollback-on-failure', argv), // ADR 0100: auto-revert ON by default (task 2149)
|
|
518
656
|
};
|
|
519
657
|
}
|
|
@@ -533,16 +671,19 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
533
671
|
' --instance <dir> consumer instance root (default: cwd)',
|
|
534
672
|
' --service <unit> systemd unit to restart after applying',
|
|
535
673
|
' --restart-cmd <cmd> explicit restart command (overrides --service)',
|
|
536
|
-
' --health-url <url> poll after restart (~30s) until 2xx; still-failing
|
|
674
|
+
' --health-url <url> poll after restart (~30s) until 2xx; a still-failing check fails the bump (auto-rollback). REQUIRED for a bump that restarts the service, unless --no-health-check.',
|
|
675
|
+
' --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)',
|
|
537
676
|
' --skip-migrate do not run `npm run migrate`',
|
|
538
677
|
' --skip-materialize do not refresh .claude/',
|
|
539
678
|
' --skip-regen-docs do not regenerate the OpenAPI spec + typed client from the new core',
|
|
540
|
-
' --
|
|
679
|
+
' --skip-pin-verify do not verify the integrity pin (tree_sha256 + tarball.sha256) after install',
|
|
680
|
+
' --require-pin treat a MISSING reference manifest as fatal (default: warn — a MISMATCH is always fatal)',
|
|
681
|
+
' --no-rollback-on-failure do NOT auto-revert on a failed install/pin-verify/migrate/health check (default: auto-rollback ON)',
|
|
541
682
|
' --dry-run show the plan; change nothing',
|
|
542
683
|
' --force skip the clean-tree + same-version guards',
|
|
543
684
|
'',
|
|
544
|
-
'Full bump: pre-flight → bump pin → npm install → migrate → materialize .claude → regen API docs+client → restart → verify.',
|
|
545
|
-
'On a failure after the pin moves (install/migrate/health), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
|
|
685
|
+
'Full bump: pre-flight → bump pin → npm install → verify integrity pin → migrate → materialize .claude → regen API docs+client → restart → verify.',
|
|
686
|
+
'On a failure after the pin moves (install/pin-verify/migrate/health), auto-rollback reverts the pin, reinstalls the previous core, restarts, and records a rolled_back ledger row (the DB is not rolled back — core migrations are additive).',
|
|
546
687
|
].join('\n') + '\n');
|
|
547
688
|
return 0;
|
|
548
689
|
}
|
|
@@ -560,6 +701,7 @@ module.exports = {
|
|
|
560
701
|
runUpgrade, parseArgs, main, rollback,
|
|
561
702
|
versionFromTgzPath, versionFromDep, readInstalledCoreVersion, readPinnedCoreVersion, writePin, writeRegistryPin, restorePin,
|
|
562
703
|
vendorTarball, gitTreeClean, preflightModules, npmInstall, runMigrate, regenerateApiArtifacts, restartService, recordLedger, pollHealth,
|
|
704
|
+
readManifest, resolveReferenceManifest, verifyInstalledPin, // task 1002216 (audit H8) — integrity-pin verification
|
|
563
705
|
CORE_PKG, ARTIFACT,
|
|
564
706
|
};
|
|
565
707
|
|
package/scripts/migrate.sh
CHANGED
|
@@ -110,6 +110,16 @@ apply_migration() {
|
|
|
110
110
|
>/dev/null
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
# --- Forward-safety notice (task 1002223 / audit H10) ---------------------
|
|
114
|
+
# `bongos upgrade` auto-rollback reverts the code pin but NOT the DB — safe only if core migrations
|
|
115
|
+
# are forward-safe (the PREVIOUS code still runs on the new schema). Surface — never block — any
|
|
116
|
+
# UNACKNOWLEDGED forward-unsafe core migration so an operator knows a rollback past it would strand
|
|
117
|
+
# old code on new schema. Fail-closed enforcement is the unit gate (tests/migrations_forward_safe.mjs);
|
|
118
|
+
# here it is advisory (`|| true`) so a lint hiccup can NEVER break a deploy.
|
|
119
|
+
if [ -f "$CORE_ROOT/scripts/gds/lint-migrations.js" ]; then
|
|
120
|
+
node "$CORE_ROOT/scripts/gds/lint-migrations.js" --warn --dir "$CORE_ROOT/migrations" || true
|
|
121
|
+
fi
|
|
122
|
+
|
|
113
123
|
# Build the ordered migration list and apply it as ONE numeric sequence by
|
|
114
124
|
# filename stem, regardless of directory:
|
|
115
125
|
# $CORE_ROOT/migrations/*.sql — core: schema + platform-universal seeds (EVERY instance).
|
package/src/bongos/auth.js
CHANGED
|
@@ -55,6 +55,20 @@ const { branding, userAgent } = require('../branding');
|
|
|
55
55
|
|
|
56
56
|
const SCOPE = 'read:user';
|
|
57
57
|
|
|
58
|
+
// Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
|
|
59
|
+
// and the CLI Device Flow ever need. The onboarding wizard
|
|
60
|
+
// (public-landing/create.html), however, lists + creates the signed-in user's own
|
|
61
|
+
// GitHub repos (task 1002099) — which `read:user` cannot do — so it OPTS IN to a
|
|
62
|
+
// broader scope. We add `public_repo`, NOT the all-or-nothing `repo`: it still
|
|
63
|
+
// lists/creates PUBLIC repos (the norm for open-source Cloud Bongos instances)
|
|
64
|
+
// but shows a far gentler consent screen and shrinks the blast radius of the
|
|
65
|
+
// token we then persist (ADR 0143). Only the wizard passes { repos:true }; every
|
|
66
|
+
// other sign-in stays identity-only. Pure + exported so the exact scope string
|
|
67
|
+
// (a security-critical value) is unit-testable.
|
|
68
|
+
function scopeForWebFlow({ repos = false } = {}) {
|
|
69
|
+
return repos ? `${SCOPE} public_repo` : SCOPE;
|
|
70
|
+
}
|
|
71
|
+
|
|
58
72
|
// In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
|
|
59
73
|
// Device flow is ephemeral; restart of the Node process invalidates pending flows
|
|
60
74
|
// (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
|
|
@@ -91,6 +105,116 @@ function authConfigured() {
|
|
|
91
105
|
return !!loadGithubCreds();
|
|
92
106
|
}
|
|
93
107
|
|
|
108
|
+
// -------------------------------------------------------------------------
|
|
109
|
+
// Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
|
|
110
|
+
// CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
|
|
111
|
+
// directly. Identity is federated; authority is NOT — the instance still runs its
|
|
112
|
+
// OWN admission gate + upsertBuilder + createSession, so the hub only answers
|
|
113
|
+
// "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
|
|
114
|
+
// unchanged fallback for self-hosted instances.
|
|
115
|
+
// -------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
// The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
|
|
118
|
+
// false so a branding hiccup degrades to the direct-GitHub path rather than
|
|
119
|
+
// breaking sign-in.
|
|
120
|
+
function idpEnabled() {
|
|
121
|
+
try { return branding().auth?.idp === true; } catch { return false; }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Resolve the hub federation config. The switch is the flag above; the hub origin
|
|
125
|
+
// + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
|
|
126
|
+
// _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
|
|
127
|
+
// branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
|
|
128
|
+
// (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
|
|
129
|
+
// flag is on AND all three resolve → callers fall back to direct GitHub.
|
|
130
|
+
function loadIdpConfig() {
|
|
131
|
+
if (!idpEnabled()) return null;
|
|
132
|
+
const origin = ic.resolveEnv('HUB_ORIGIN');
|
|
133
|
+
const clientId = ic.resolveEnv('HUB_CLIENT_ID');
|
|
134
|
+
const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
|
|
135
|
+
if (origin && clientId && clientSecret) {
|
|
136
|
+
return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
|
|
137
|
+
}
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function idpConfigured() {
|
|
142
|
+
return !!loadIdpConfig();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
|
|
146
|
+
// builder is the instance's configured firstAdmin, else builder. NEVER a rank.
|
|
147
|
+
function membershipRole({ githubLogin } = {}) {
|
|
148
|
+
const fa = firstAdminLogin();
|
|
149
|
+
if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
|
|
150
|
+
return 'builder';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
|
|
154
|
+
// github_id builds on project <client_id>, coarse role <role>". Runs on every
|
|
155
|
+
// FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
|
|
156
|
+
// project still appears in the builder's My Projects with no hub browser session.
|
|
157
|
+
// Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
|
|
158
|
+
// the onboarding stage tick). No-op on a self-hosted instance (idp unset).
|
|
159
|
+
async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
|
|
160
|
+
const idp = loadIdpConfig();
|
|
161
|
+
if (!idp) return;
|
|
162
|
+
try {
|
|
163
|
+
await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
|
|
164
|
+
method: 'POST',
|
|
165
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
166
|
+
body: JSON.stringify({
|
|
167
|
+
client_id: idp.clientId,
|
|
168
|
+
client_secret: idp.clientSecret,
|
|
169
|
+
github_id: githubId,
|
|
170
|
+
github_login: githubLogin,
|
|
171
|
+
display_name: displayName ?? null,
|
|
172
|
+
avatar_url: avatarUrl ?? null,
|
|
173
|
+
role: membershipRole({ githubLogin }),
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
|
|
182
|
+
// builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
|
|
183
|
+
// for the internal scouting directory. Fires on every federated sign-in ("on key
|
|
184
|
+
// events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
|
|
185
|
+
// not federated — never fails a sign-in.
|
|
186
|
+
async function reportActivityRollup({ builderId, githubId }) {
|
|
187
|
+
const idp = loadIdpConfig();
|
|
188
|
+
if (!idp) return;
|
|
189
|
+
try {
|
|
190
|
+
// Snapshot the builder's talent signals directly from the DB (auth.js is a
|
|
191
|
+
// kernel file — it uses pool, never a non-kernel domain module like metrics.js).
|
|
192
|
+
const b = await pool.query(
|
|
193
|
+
'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
|
|
194
|
+
[builderId],
|
|
195
|
+
);
|
|
196
|
+
if (!b.rows[0]) return;
|
|
197
|
+
const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
|
|
198
|
+
const row = b.rows[0];
|
|
199
|
+
await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
|
|
200
|
+
method: 'POST',
|
|
201
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
client_id: idp.clientId,
|
|
204
|
+
client_secret: idp.clientSecret,
|
|
205
|
+
github_id: githubId,
|
|
206
|
+
credits: Number(row.total_credits) || 0,
|
|
207
|
+
tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
|
|
208
|
+
karma: Number(row.karma) || 0,
|
|
209
|
+
rank: row.rank || null,
|
|
210
|
+
disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
|
|
211
|
+
}),
|
|
212
|
+
});
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
94
218
|
// Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
|
|
95
219
|
// sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
|
|
96
220
|
// "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
|
|
@@ -201,7 +325,11 @@ async function exchangeCodeForToken(code, redirectUri) {
|
|
|
201
325
|
});
|
|
202
326
|
const data = await res.json();
|
|
203
327
|
if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
|
|
204
|
-
|
|
328
|
+
// Return the GRANTED scope alongside the token. GitHub echoes back the scopes
|
|
329
|
+
// the user actually consented to (space/comma-separated) — the caller checks it
|
|
330
|
+
// before persisting a repo-capable token, so a user who declined the broader
|
|
331
|
+
// consent never has one stored (task 1002099 / ADR 0143).
|
|
332
|
+
return { accessToken: data.access_token, scope: data.scope || '' };
|
|
205
333
|
}
|
|
206
334
|
|
|
207
335
|
// -------------------------------------------------------------------------
|
|
@@ -360,19 +488,21 @@ async function pollDeviceFlow(deviceCode, userAgent) {
|
|
|
360
488
|
// Web Flow (browser)
|
|
361
489
|
// -------------------------------------------------------------------------
|
|
362
490
|
|
|
363
|
-
function buildAuthorizeUrl(state, redirectUri) {
|
|
491
|
+
function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
|
|
364
492
|
const creds = loadGithubCreds();
|
|
365
493
|
if (!creds) throw new Error('GitHub OAuth credentials not configured');
|
|
366
494
|
const u = new URL('https://github.com/login/oauth/authorize');
|
|
367
495
|
u.searchParams.set('client_id', creds.client_id);
|
|
368
496
|
u.searchParams.set('redirect_uri', redirectUri);
|
|
369
|
-
|
|
497
|
+
// `repos:true` (the onboarding wizard only) requests the broader repo scope; every
|
|
498
|
+
// other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
|
|
499
|
+
u.searchParams.set('scope', scopeForWebFlow({ repos }));
|
|
370
500
|
u.searchParams.set('state', state);
|
|
371
501
|
return u.toString();
|
|
372
502
|
}
|
|
373
503
|
|
|
374
|
-
async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
375
|
-
const accessToken = await exchangeCodeForToken(code, redirectUri);
|
|
504
|
+
async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
|
|
505
|
+
const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
|
|
376
506
|
const ghUser = await fetchGithubUser(accessToken);
|
|
377
507
|
// Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
|
|
378
508
|
// with a GitHub account. A first-time visitor must have been approved by an
|
|
@@ -423,9 +553,225 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
|
|
|
423
553
|
});
|
|
424
554
|
// V3.R75 (#294): tick the onboarding state machine. Best-effort.
|
|
425
555
|
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
556
|
+
// Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
|
|
557
|
+
// repo access, hand the GitHub access token + granted scope back so the web
|
|
558
|
+
// callback can persist it (encrypted, short-TTL) for the repo picker/creator.
|
|
559
|
+
// auth.js stays kernel-clean — it never writes the token to the DB itself; the
|
|
560
|
+
// token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
|
|
561
|
+
// even returns it.
|
|
562
|
+
const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
|
|
563
|
+
return { token, builder, expiresAt, ...captured };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// -------------------------------------------------------------------------
|
|
567
|
+
// Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
|
|
568
|
+
// -------------------------------------------------------------------------
|
|
569
|
+
|
|
570
|
+
// Build the hub's authorize URL. The instance redirects the browser here (instead
|
|
571
|
+
// of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
|
|
572
|
+
// instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
|
|
573
|
+
// by provisioning); state is the same CSRF nonce the callback validates.
|
|
574
|
+
// PURE — construct the hub authorize URL (testable without branding/env). Trailing
|
|
575
|
+
// slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
|
|
576
|
+
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
577
|
+
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
|
|
578
|
+
u.searchParams.set('client_id', clientId);
|
|
579
|
+
u.searchParams.set('redirect_uri', redirectUri);
|
|
580
|
+
u.searchParams.set('state', state);
|
|
581
|
+
return u.toString();
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function buildIdpAuthorizeUrl(state, redirectUri) {
|
|
585
|
+
const idp = loadIdpConfig();
|
|
586
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
587
|
+
return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// Server-to-server: redeem the hub authorization code for identity. Authenticates
|
|
591
|
+
// with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
|
|
592
|
+
// (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
|
|
593
|
+
async function exchangeIdpCode({ code, redirectUri, idp }) {
|
|
594
|
+
const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
|
|
595
|
+
method: 'POST',
|
|
596
|
+
headers: {
|
|
597
|
+
'Content-Type': 'application/json',
|
|
598
|
+
Accept: 'application/json',
|
|
599
|
+
'User-Agent': userAgent(),
|
|
600
|
+
},
|
|
601
|
+
body: JSON.stringify({
|
|
602
|
+
client_id: idp.clientId,
|
|
603
|
+
client_secret: idp.clientSecret,
|
|
604
|
+
code,
|
|
605
|
+
redirect_uri: redirectUri,
|
|
606
|
+
}),
|
|
607
|
+
});
|
|
608
|
+
if (!res.ok) {
|
|
609
|
+
const text = await res.text().catch(() => '');
|
|
610
|
+
throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
|
|
611
|
+
}
|
|
612
|
+
const data = await res.json();
|
|
613
|
+
if (!data || data.github_id == null) {
|
|
614
|
+
throw new Error('hub /sso/token returned no identity');
|
|
615
|
+
}
|
|
616
|
+
return data;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Complete the instance web sign-in via the hub. Sources identity from the hub's
|
|
620
|
+
// /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
|
|
621
|
+
// maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
|
|
622
|
+
// identity SOURCE differs (the hub, not github.com). The shared PURE gate
|
|
623
|
+
// (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
|
|
624
|
+
// path (SR-19). Returns the same { token, builder, expiresAt } shape.
|
|
625
|
+
async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
|
|
626
|
+
const idp = loadIdpConfig();
|
|
627
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
628
|
+
const identity = await exchangeIdpCode({ code, redirectUri, idp });
|
|
629
|
+
const ghId = identity.github_id;
|
|
630
|
+
const ghLogin = identity.github_login;
|
|
631
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
632
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
633
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
634
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
635
|
+
const err = new Error('not_approved');
|
|
636
|
+
err.code = 'NOT_APPROVED';
|
|
637
|
+
throw err;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
let builder = await upsertBuilder({
|
|
641
|
+
githubId: ghId,
|
|
642
|
+
githubLogin: ghLogin,
|
|
643
|
+
displayName: identity.display_name || ghLogin,
|
|
644
|
+
avatarUrl: identity.avatar_url,
|
|
645
|
+
});
|
|
646
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
647
|
+
const token = newSessionToken();
|
|
648
|
+
const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
|
|
649
|
+
await createSession({
|
|
650
|
+
builderId: builder.id,
|
|
651
|
+
token,
|
|
652
|
+
source: 'web',
|
|
653
|
+
userAgent: ua ?? null,
|
|
654
|
+
expiresAt,
|
|
655
|
+
});
|
|
656
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
657
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated web sign-in.
|
|
658
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url });
|
|
659
|
+
reportActivityRollup({ builderId: builder.id, githubId: ghId });
|
|
426
660
|
return { token, builder, expiresAt };
|
|
427
661
|
}
|
|
428
662
|
|
|
663
|
+
// -------------------------------------------------------------------------
|
|
664
|
+
// Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
|
|
665
|
+
// assertion OFFLINE and mints its own cli session. The CLI runs the device flow
|
|
666
|
+
// against the HUB (GitHub device codes can't cross apps), the hub signs an
|
|
667
|
+
// audience-scoped Ed25519 assertion, and the instance verifies it here with the
|
|
668
|
+
// hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
|
|
669
|
+
// -------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
|
|
672
|
+
|
|
673
|
+
// The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
|
|
674
|
+
// instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
|
|
675
|
+
function loadHubPublicKey() {
|
|
676
|
+
const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
|
|
677
|
+
if (!b64) return null;
|
|
678
|
+
try {
|
|
679
|
+
return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
|
|
680
|
+
} catch (err) {
|
|
681
|
+
console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Verify a hub-signed assertion (the compact-JWT format minted by the hub's
|
|
687
|
+
// platform-identity/hub-keys.js — kept in lockstep across the core<->module
|
|
688
|
+
// boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
|
|
689
|
+
// public key), then the issuer, audience (must equal THIS instance's client_id),
|
|
690
|
+
// and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
|
|
691
|
+
// regardless of the header's `alg` field, so there is no algorithm-confusion attack.
|
|
692
|
+
// Returns the validated payload, or throws. Pure over its inputs (testable).
|
|
693
|
+
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
694
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
695
|
+
const parts = String(token || '').split('.');
|
|
696
|
+
if (parts.length !== 3) throw new Error('malformed assertion');
|
|
697
|
+
const [h, p, s] = parts;
|
|
698
|
+
let header;
|
|
699
|
+
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
700
|
+
catch { throw new Error('malformed assertion header'); }
|
|
701
|
+
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
|
|
702
|
+
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
703
|
+
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
704
|
+
if (!ok) throw new Error('assertion signature invalid');
|
|
705
|
+
let payload;
|
|
706
|
+
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
707
|
+
catch { throw new Error('malformed assertion payload'); }
|
|
708
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
|
|
709
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
|
|
710
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
|
|
711
|
+
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
712
|
+
return payload;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
716
|
+
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
717
|
+
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
718
|
+
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
719
|
+
// short window and that the aud binding is the primary control.
|
|
720
|
+
const _seenAssertionJti = new Map(); // jti -> expiry ms
|
|
721
|
+
function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
722
|
+
for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
|
|
723
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
724
|
+
if (_seenAssertionJti.has(jti)) return true;
|
|
725
|
+
_seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
726
|
+
return false;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
730
|
+
// admission gate + upsertBuilder + createSession(source='cli') as the direct device
|
|
731
|
+
// flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
|
|
732
|
+
async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
733
|
+
const idp = loadIdpConfig();
|
|
734
|
+
if (!idp) throw new Error('platform IdP not configured');
|
|
735
|
+
const publicKey = loadHubPublicKey();
|
|
736
|
+
if (!publicKey) throw new Error('hub public key not configured');
|
|
737
|
+
const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
|
|
738
|
+
// Reject a replayed assertion (same jti) within its window.
|
|
739
|
+
if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
|
|
740
|
+
throw new Error('assertion already used');
|
|
741
|
+
}
|
|
742
|
+
const ghId = payload.sub;
|
|
743
|
+
const ghLogin = payload.github_login;
|
|
744
|
+
const existing = await getBuilderByGithubId(ghId);
|
|
745
|
+
if (!existing && !openEnrollmentEnabled()) {
|
|
746
|
+
const approved = await hasInvitedAccessRequest(ghLogin);
|
|
747
|
+
if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
|
|
748
|
+
const err = new Error('not_approved');
|
|
749
|
+
err.code = 'NOT_APPROVED';
|
|
750
|
+
throw err;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
let builder = await upsertBuilder({
|
|
754
|
+
githubId: ghId,
|
|
755
|
+
githubLogin: ghLogin,
|
|
756
|
+
displayName: payload.display_name || ghLogin,
|
|
757
|
+
avatarUrl: payload.avatar_url,
|
|
758
|
+
});
|
|
759
|
+
builder = await maybeSeatFirstAdmin(builder);
|
|
760
|
+
const token = newSessionToken();
|
|
761
|
+
await createSession({
|
|
762
|
+
builderId: builder.id,
|
|
763
|
+
token,
|
|
764
|
+
source: 'cli',
|
|
765
|
+
userAgent: ua ?? null,
|
|
766
|
+
expiresAt: null,
|
|
767
|
+
});
|
|
768
|
+
await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
|
|
769
|
+
// Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated CLI sign-in.
|
|
770
|
+
reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url });
|
|
771
|
+
reportActivityRollup({ builderId: builder.id, githubId: ghId });
|
|
772
|
+
return { token, builder };
|
|
773
|
+
}
|
|
774
|
+
|
|
429
775
|
// -------------------------------------------------------------------------
|
|
430
776
|
// Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
|
|
431
777
|
// -------------------------------------------------------------------------
|
|
@@ -969,6 +1315,9 @@ function requireDiscordBot(req, res, next) {
|
|
|
969
1315
|
|
|
970
1316
|
module.exports = {
|
|
971
1317
|
authConfigured,
|
|
1318
|
+
idpEnabled,
|
|
1319
|
+
loadIdpConfig,
|
|
1320
|
+
idpConfigured,
|
|
972
1321
|
openEnrollmentEnabled,
|
|
973
1322
|
firstTimeAdmissionBlocked,
|
|
974
1323
|
firstAdminLogin,
|
|
@@ -976,7 +1325,15 @@ module.exports = {
|
|
|
976
1325
|
startDeviceFlow,
|
|
977
1326
|
pollDeviceFlow,
|
|
978
1327
|
buildAuthorizeUrl,
|
|
1328
|
+
scopeForWebFlow,
|
|
1329
|
+
buildIdpAuthorizeUrl,
|
|
1330
|
+
idpAuthorizeUrl,
|
|
979
1331
|
completeWebFlow,
|
|
1332
|
+
completeIdpFlow,
|
|
1333
|
+
loadHubPublicKey,
|
|
1334
|
+
verifyHubAssertion,
|
|
1335
|
+
completeDeviceExchange,
|
|
1336
|
+
membershipRole,
|
|
980
1337
|
extractTokens,
|
|
981
1338
|
resolveSession,
|
|
982
1339
|
allowBoxScope,
|