@bongos/core 1.17.3 → 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.
Files changed (163) hide show
  1. package/.bongos-core.json +212 -332
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/config/branding.neutral.json +5 -1
  9. package/docker-entrypoint.sh +0 -0
  10. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  11. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  12. package/docs/adr/README.md +6 -0
  13. package/docs/api/openapi.json +1630 -632
  14. package/docs/api-reference.md +50 -15
  15. package/docs/file-map.md +0 -18
  16. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  17. package/docs/onboarding/diagrams/assertions.json +5 -9
  18. package/docs/recipes/packaging-the-core.md +13 -0
  19. package/docs/recipes/upgrading-the-core.md +5 -2
  20. package/migrations/039_newcomer_friendly_column.sql +5 -0
  21. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  22. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  23. package/modules/hall-ui/public/builders.js +37 -0
  24. package/modules/hall-ui/public/goals-page.js +98 -176
  25. package/modules/hall-ui/public/goals.css +183 -0
  26. package/modules/hall-ui/public/goals.html +35 -261
  27. package/modules/hall-ui/public/goals.js +7 -2
  28. package/modules/hall-ui/public/hall-widgets.js +12 -4
  29. package/modules/hall-ui/public/my-projects.html +78 -0
  30. package/modules/hall-ui/public/my-projects.js +142 -0
  31. package/modules/hall-ui/public/scouting.html +45 -0
  32. package/modules/hall-ui/public/scouting.js +73 -0
  33. package/modules/hall-ui/public/settings.css +355 -0
  34. package/modules/hall-ui/public/settings.html +86 -414
  35. package/modules/hall-ui/public/settings.js +96 -35
  36. package/modules/hall-ui/public/style.css +31 -0
  37. package/modules/hall-ui/public/watch.css +184 -0
  38. package/modules/hall-ui/public/watch.html +111 -285
  39. package/modules/hall-ui/public/watch.js +59 -21
  40. package/modules/status-ui/public/explore.html +54 -0
  41. package/modules/status-ui/public/explore.js +67 -0
  42. package/package-lock.json +2 -2
  43. package/package.json +1 -1
  44. package/public-landing/create.html +292 -19
  45. package/public-landing/index.html +11 -76
  46. package/scripts/cron/style_guide_audit.sh +0 -0
  47. package/scripts/gds/api.js +0 -0
  48. package/scripts/gds/architect-audit.js +0 -0
  49. package/scripts/gds/audit-authorship.sh +0 -0
  50. package/scripts/gds/audit-replay.sh +0 -0
  51. package/scripts/gds/box.js +0 -0
  52. package/scripts/gds/capture.js +0 -0
  53. package/scripts/gds/cli-lib.js +11 -5
  54. package/scripts/gds/conductor-dispatch.js +0 -0
  55. package/scripts/gds/coreB-carve-proof.sh +0 -0
  56. package/scripts/gds/db-backup-nightly.sh +0 -0
  57. package/scripts/gds/db-backup-offsite.sh +0 -0
  58. package/scripts/gds/doctor.js +0 -0
  59. package/scripts/gds/grade-smoke.js +0 -0
  60. package/scripts/gds/hooks/pre-push.js +0 -0
  61. package/scripts/gds/install-git-hooks.js +0 -0
  62. package/scripts/gds/lint-migrations.js +102 -0
  63. package/scripts/gds/login.js +42 -10
  64. package/scripts/gds/main-audit.js +0 -0
  65. package/scripts/gds/mirror-redact.js +5 -0
  66. package/scripts/gds/oauth-secret.js +0 -0
  67. package/scripts/gds/onboarding-analyze.js +0 -0
  68. package/scripts/gds/onboarding-ship.js +0 -0
  69. package/scripts/gds/package-core.js +263 -4
  70. package/scripts/gds/paste-token.js +0 -0
  71. package/scripts/gds/portability-smoke-art.sh +0 -0
  72. package/scripts/gds/portability-smoke-memory.sh +0 -0
  73. package/scripts/gds/provision.js +270 -11
  74. package/scripts/gds/publish-manifest.js +15 -0
  75. package/scripts/gds/push-main.js +0 -0
  76. package/scripts/gds/render-ideas.js +0 -0
  77. package/scripts/gds/run-unit-tests.js +38 -14
  78. package/scripts/gds/security-baseline-check.sh +0 -0
  79. package/scripts/gds/session.js +0 -0
  80. package/scripts/gds/setup.js +41 -0
  81. package/scripts/gds/ship.js +126 -32
  82. package/scripts/gds/smoke-batch-claim.sh +0 -0
  83. package/scripts/gds/smoke-batch-release.sh +0 -0
  84. package/scripts/gds/smoke-box.sh +0 -0
  85. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  86. package/scripts/gds/smoke-dependencies.sh +0 -0
  87. package/scripts/gds/smoke-write.sh +0 -0
  88. package/scripts/gds/upgrade.js +147 -5
  89. package/scripts/hall-preview/server.js +18 -1
  90. package/scripts/migrate.sh +10 -0
  91. package/scripts/migrate_cost_ledger.sh +0 -0
  92. package/scripts/render-diagrams.sh +0 -0
  93. package/scripts/security/dep-audit.js +0 -0
  94. package/scripts/setup-builder.js +0 -0
  95. package/scripts/setup-builder.sh +0 -0
  96. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  97. package/src/bongos/auth.js +362 -5
  98. package/src/bongos/db.js +66 -0
  99. package/src/bongos/github-repos.js +93 -0
  100. package/src/bongos/routes/auth.js +93 -11
  101. package/src/bongos/routes/github.js +90 -0
  102. package/src/bongos/routes/instance.js +15 -3
  103. package/src/bongos/routes.js +2 -0
  104. package/src/branding.js +17 -2
  105. package/src/instance-config.js +16 -0
  106. package/src/module-api.js +48 -1
  107. package/tests/branding.mjs +29 -23
  108. package/tests/github_repos_helpers.mjs +91 -0
  109. package/tests/github_token_store.mjs +121 -0
  110. package/tests/idp_device_exchange.mjs +105 -0
  111. package/tests/idp_federation.mjs +100 -0
  112. package/tests/migrations_forward_safe.mjs +86 -0
  113. package/tests/module_api.mjs +1 -1
  114. package/tests/package_core.mjs +246 -1
  115. package/tests/provision.mjs +226 -0
  116. package/tests/pruned_lockfile_integrity.mjs +79 -0
  117. package/tests/ship_ci_deploy.mjs +60 -0
  118. package/tests/upgrade.mjs +254 -5
  119. package/.gitignore +0 -91
  120. package/scripts/hall-preview/fixtures/access-requests.json +0 -1
  121. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  122. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  123. package/scripts/hall-preview/fixtures/audit-log.json +0 -1
  124. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -1
  125. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  126. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  127. package/scripts/hall-preview/fixtures/builders__roster.json +0 -1
  128. package/scripts/hall-preview/fixtures/goals.json +0 -502
  129. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  130. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  131. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  132. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  133. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  134. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  135. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  136. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  137. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -1
  138. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  139. package/scripts/hall-preview/fixtures/instance.json +0 -124
  140. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  141. package/scripts/hall-preview/fixtures/me.json +0 -476
  142. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  143. package/scripts/hall-preview/fixtures/override-requests.json +0 -1
  144. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  145. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  146. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  147. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  148. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  149. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  150. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  151. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  152. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  153. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  154. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  155. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  156. package/scripts/hall-preview/fixtures/security__docs.json +0 -1
  157. package/scripts/hall-preview/fixtures/security__reports.json +0 -1
  158. package/scripts/hall-preview/fixtures/sessions__search.json +0 -1
  159. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  160. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  161. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  162. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  163. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -84,8 +84,22 @@ const GRADER_TIMEOUT_MS = 240_000; // 4 min — Opus on a big diff with Read too
84
84
  // feature worktree this script lives in. Falls back to two-levels-up if we
85
85
  // can't ask git (e.g. fresh clone with no `main` worktree set up yet).
86
86
  const REPO_ROOT = findMainWorktree(__dirname) || path.resolve(__dirname, '..', '..');
87
- const { playSound } = require(path.resolve(__dirname, '../../.claude/sounds/play.js'));
88
- const DROPLET = 'lars@104.236.254.243';
87
+ // Sound playback is COSMETIC — the ship pipeline must NEVER depend on it. Guard
88
+ // the require so a missing/broken player (e.g. .claude/sounds/ absent on a fresh
89
+ // clone or after a re-seed) degrades to a silent no-op instead of crashing ship.js
90
+ // at load. A cosmetic feature can't be allowed to break shipping (task 1002199).
91
+ const { playSound } = (() => {
92
+ try {
93
+ return require(path.resolve(__dirname, '../../.claude/sounds/play.js'));
94
+ } catch {
95
+ return { playSound: () => {} };
96
+ }
97
+ })();
98
+ // The laptop-mode SSH deploy target is resolved at land time from config/deploy.json
99
+ // ("sshTarget") or <PREFIX>_DEPLOY_SSH_TARGET — see resolveDeployTarget(). There is NO
100
+ // hardcoded default: the core carries no deploy target of its own. The founder
101
+ // instance's old game-box SSH target used to be baked in here, which post-migration
102
+ // meant a laptop-mode ship deployed to the WRONG server (task 1002202).
89
103
  const BEHIND_MAIN_LIMIT = 5;
90
104
 
91
105
  // arg() — the =-aware flag-value parser now lives in cli-lib (the canonical
@@ -752,9 +766,13 @@ function buildMergeArgv(branch, valueSummary) {
752
766
  // Two ways the confirmed→shipped land can run, chosen by a SINGLE committed
753
767
  // switch (config/deploy.json `mode`, env OTB_DEPLOY_MODE overrides):
754
768
  //
755
- // 'laptop' (default) — the operator's machine merges to main locally and
756
- // SSHes `~/deploy.sh` on the droplet. Today's behavior, byte-for-byte.
757
- // Requires the droplet SSH key, so only the operator can land.
769
+ // 'laptop' (default) — the operator's machine merges to main locally and, IFF a
770
+ // deploy target is configured (config/deploy.json "sshTarget" or
771
+ // <PREFIX>_DEPLOY_SSH_TARGET, resolveDeployTarget()), SSHes `~/deploy.sh` on it.
772
+ // The core carries NO target of its own, so by default this is a merge-to-main
773
+ // with no SSH deploy — a change reaches a live instance via package-core +
774
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). When a target IS
775
+ // set it requires that host's SSH key, so only the operator can land.
758
776
  //
759
777
  // 'ci' — NO builder box holds the prod key. ship pushes the branch, opens a
760
778
  // PR, and enables GitHub auto-merge; once required checks pass, GitHub
@@ -805,12 +823,7 @@ function effectiveDeployMode(mode, { canPushLocally, envForced } = {}) {
805
823
  }
806
824
 
807
825
  function resolveDeployMode() {
808
- let configRaw = null;
809
- try {
810
- configRaw = fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
811
- } catch (_) {
812
- /* no config file → safe default */
813
- }
826
+ const configRaw = readDeployConfigRaw();
814
827
  const mode = parseDeployMode(process.env.OTB_DEPLOY_MODE, configRaw);
815
828
  const envForced = ['ci', 'laptop'].includes(
816
829
  String(process.env.OTB_DEPLOY_MODE || '').trim().toLowerCase()
@@ -824,6 +837,63 @@ function resolveDeployMode() {
824
837
  return mode;
825
838
  }
826
839
 
840
+ // readDeployConfigRaw — raw contents of config/deploy.json (or null if absent /
841
+ // unreadable). One reader shared by resolveDeployMode (the mode switch) and
842
+ // resolveDeployTarget (the SSH target) so they never diverge on where the file is.
843
+ function readDeployConfigRaw() {
844
+ try {
845
+ return fs.readFileSync(path.join(REPO_ROOT, 'config', 'deploy.json'), 'utf8');
846
+ } catch (_) {
847
+ return null; // no config file → callers apply their safe default
848
+ }
849
+ }
850
+
851
+ // safeBranding — the instance brand pack, or null if it can't be read. Guarded the
852
+ // same way ship.js guards the sound player (line ~91): a deploy must never crash
853
+ // because the brand file is missing on a fresh / re-seeded checkout.
854
+ function safeBranding() {
855
+ try {
856
+ return require('../../src/branding').branding();
857
+ } catch (_) {
858
+ return null;
859
+ }
860
+ }
861
+
862
+ // resolveDeployTarget — WHERE a laptop-mode ship SSHes `~/deploy.sh`, and which
863
+ // /healthz it re-probes when deploy.sh's own health check races the restart window.
864
+ // Pure (env + raw config + branding → target) and exported so the precedence and the
865
+ // safe "no target" default are unit-tested without touching fs/env/network.
866
+ //
867
+ // There is deliberately NO hardcoded default. The founder instance's old game-box
868
+ // SSH target was baked in here, so a laptop-mode ship from a migrated
869
+ // checkout SSHed the WRONG server (task 1002202). The core carries no deploy target
870
+ // of its own — under the standalone model a core change reaches a live instance via
871
+ // package-core + `bongos upgrade` (pin-bump), not an SSH deploy (ADR 0108). An
872
+ // instance whose OWN checkout is its live server opts back into laptop-SSH deploy by
873
+ // setting the target:
874
+ // config/deploy.json → { "sshTarget": "user@host", "healthzUrl": "https://…/healthz" }
875
+ // env → <PREFIX>_DEPLOY_SSH_TARGET, <PREFIX>_DEPLOY_HEALTHZ_URL
876
+ // healthzUrl, when unset, falls back to the instance's own publicOrigin + /healthz
877
+ // (never a hardcoded host); null when even that is unknown (re-probe then skipped).
878
+ function resolveDeployTarget({ env = process.env, configRaw = null, branding = null } = {}) {
879
+ const ic = require('../../src/instance-config');
880
+ let cfg = {};
881
+ if (configRaw) {
882
+ try { cfg = JSON.parse(configRaw) || {}; } catch (_) { cfg = {}; }
883
+ }
884
+ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
885
+
886
+ const sshTarget = ic.resolveEnv('DEPLOY_SSH_TARGET', { env }) || str(cfg.sshTarget) || null;
887
+
888
+ let healthzUrl = ic.resolveEnv('DEPLOY_HEALTHZ_URL', { env }) || str(cfg.healthzUrl) || null;
889
+ const publicOrigin = branding && branding.domains && branding.domains.publicOrigin;
890
+ if (!healthzUrl && str(publicOrigin)) {
891
+ try { healthzUrl = new URL('/healthz', publicOrigin).toString(); }
892
+ catch (_) { /* malformed origin → leave healthzUrl null */ }
893
+ }
894
+ return { sshTarget, healthzUrl };
895
+ }
896
+
827
897
  // gh runners — mirror gitArgvOk/gitOk but for the GitHub CLI. No shell, ever:
828
898
  // branch names + DB-sourced value_summary reach `gh` as literal argv elements
829
899
  // (same injection discipline as #681). ghArgvOk = boolean side-effect; ghJson =
@@ -2119,26 +2189,48 @@ async function autoMerge(taskId, valueSummary, { dbOnly = false } = {}) {
2119
2189
  return false;
2120
2190
  }
2121
2191
 
2122
- console.log(' triggering deploy on droplet...');
2123
- // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it to
2124
- // /Users/<mac-user>/deploy.sh before ssh transmits the command. With
2125
- // the quotes, ssh sends the literal `~/deploy.sh` and the REMOTE shell
2126
- // expands it to /home/lars/deploy.sh. (Matches /merge-mode SKILL.md:81.)
2127
- if (!shellOk(`ssh -o ConnectTimeout=10 ${DROPLET} "~/deploy.sh"`)) {
2128
- // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2129
- // service-restart window (Node not yet bound) and exit non-zero even though
2130
- // the deploy succeeded — which used to false-fail the ship and force a
2131
- // needless /merge-mode. Before concluding failure, re-probe the public
2132
- // /healthz with backoff (curl's own --retry; cross-platform, no sleep).
2133
- // --retry-all-errors retries connection-refused during the bind window;
2134
- // -f makes a non-200 an error so a 502/503 also retries. If it comes up
2135
- // within the window the deploy really succeeded; only then-still-down fails.
2136
- console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2137
- if (shellOk('curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors https://amazonprimea.com/healthz -o /dev/null')) {
2138
- console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2139
- } else {
2140
- console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2141
- return false;
2192
+ // Deploy step. The core carries NO deploy target of its own — under the
2193
+ // standalone model a core change reaches a live instance via package-core +
2194
+ // `bongos upgrade` (pin-bump), NOT an SSH deploy (ADR 0108). So unless an
2195
+ // instance whose own checkout IS its live server has configured a target
2196
+ // (config/deploy.json "sshTarget" or <PREFIX>_DEPLOY_SSH_TARGET), the land is
2197
+ // complete at "merged + pushed to main" and the SSH deploy is skipped. (This
2198
+ // used to SSH a hardcoded target — the founder instance's old game box, i.e. the
2199
+ // WRONG server post-migration. task 1002202.)
2200
+ const { sshTarget, healthzUrl } = resolveDeployTarget({
2201
+ configRaw: readDeployConfigRaw(),
2202
+ branding: safeBranding(),
2203
+ });
2204
+ if (!sshTarget) {
2205
+ console.log(' merged + pushed to main. No deploy target configured, so no SSH deploy —');
2206
+ console.log(' expected for the core. To publish to a live instance run the standalone release');
2207
+ console.log(' flow: package-core → bongos upgrade (pin-bump), per ADR 0108. (Not an SSH deploy.)');
2208
+ } else {
2209
+ console.log(` triggering deploy on ${sshTarget}...`);
2210
+ // Quote ~/deploy.sh so the LOCAL shell doesn't tilde-expand it before ssh
2211
+ // transmits the command; with the quotes ssh sends the literal `~/deploy.sh`
2212
+ // and the REMOTE shell expands it. (Matches /merge-mode SKILL.md:81.)
2213
+ if (!shellOk(`ssh -o ConnectTimeout=10 ${sshTarget} "~/deploy.sh"`)) {
2214
+ // task 984 / idea 180: ~/deploy.sh's internal /healthz curl can race the
2215
+ // service-restart window (Node not yet bound) and exit non-zero even though
2216
+ // the deploy succeeded — which used to false-fail the ship and force a
2217
+ // needless /merge-mode. Before concluding failure, re-probe the CONFIGURED
2218
+ // /healthz with backoff (curl's own --retry; cross-platform, no sleep) —
2219
+ // never a hardcoded host. --retry-all-errors retries connection-refused
2220
+ // during the bind window; -f makes a non-200 an error so a 502/503 also
2221
+ // retries. If it comes up within the window the deploy really succeeded.
2222
+ if (healthzUrl) {
2223
+ console.log(' auto-merge: ~/deploy.sh returned non-zero — re-probing /healthz with backoff before concluding...');
2224
+ if (shellOk(`curl -fsS --max-time 10 --retry 5 --retry-delay 3 --retry-all-errors ${JSON.stringify(healthzUrl)} -o /dev/null`)) {
2225
+ console.log(' auto-merge: /healthz 200 on re-probe — deploy actually succeeded (deploy.sh raced the restart window). Proceeding.');
2226
+ } else {
2227
+ console.log(' auto-merge: deploy failed (/healthz never returned 200 after retries); task stays at confirmed');
2228
+ return false;
2229
+ }
2230
+ } else {
2231
+ console.log(' auto-merge: ~/deploy.sh returned non-zero and no healthzUrl configured to re-probe; task stays at confirmed');
2232
+ return false;
2233
+ }
2142
2234
  }
2143
2235
  }
2144
2236
 
@@ -3538,7 +3630,9 @@ module.exports = {
3538
3630
  gitArgvOk, buildMergeArgv, sanitizeGitHubRefs,
3539
3631
  // #679 / ADR 0042: CI deploy mode — pure helpers exported for the regression
3540
3632
  // test (mode precedence/safe-default, no-shell PR argv, deploy-run match).
3541
- parseDeployMode, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
3633
+ // task 1002202: resolveDeployTarget — SSH target / healthz precedence + the safe
3634
+ // "no target" default (the core has none), exported for the same reason.
3635
+ parseDeployMode, resolveDeployTarget, buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded,
3542
3636
  // task 1049 (B): pure CI-land outcome classifier — a timed-out-but-armed PR is
3543
3637
  // 'pending' (success), not failure. Exported so the semantics are unit-tested.
3544
3638
  classifyLandOutcome,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -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.healthUrl) {
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 is a warning',
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
- ' --no-rollback-on-failure do NOT auto-revert on a failed install/migrate/health check (default: auto-rollback ON)',
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
 
@@ -19,6 +19,8 @@
19
19
  // window.__MODULES__ (verifies moduleOn() gating)
20
20
  // --empty serve every fixture with its arrays emptied — the
21
21
  // empty-state screenshot pass
22
+ // --rank archon override the /me fixture's builder.rank so the Archon-gated
23
+ // pages (watch, harbor, gate) render their content in preview
22
24
  //
23
25
  // Browse http://builders.localhost:<port>/ — Chromium resolves *.localhost,
24
26
  // and the `builders.` host prefix exercises the real subdomain serving path.
@@ -39,6 +41,11 @@ function arg(name) {
39
41
  }
40
42
  const PORT = Number(arg('--port')) || 4600;
41
43
  const EMPTY = process.argv.includes('--empty');
44
+ // --rank: view the hall as a different rank — the /me fixture's builder.rank is
45
+ // overridden in the response, so the Archon-gated pages (watch, harbor, gate)
46
+ // render their real content instead of the sealed notice. Preview-only theatre:
47
+ // the real pages are server-gated (requireArchonPage / ADR 0016).
48
+ const RANK_OVERRIDE = arg('--rank');
42
49
  const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.trim()).filter(Boolean);
43
50
  // --modules-on: force-report modules as enabled — a dev box's local config
44
51
  // often lacks prod's instance modules (provisioning, dev-box), and their
@@ -68,6 +75,10 @@ const FIXTURE_FALLBACKS = [
68
75
  [/^goals\/\d+\/requests$/, 'goals__requests'],
69
76
  [/^goals\/\d+$/, 'goals__detail'],
70
77
  [/^tasks\/\d+$/, 'tasks__detail'],
78
+ // the #/idea/N + #/blocker/N deep-link overlay (task-detail.js) — one record
79
+ // fixture serves any id so the overlay seam assertion renders real content.
80
+ [/^inbox\/\d+$/, 'inbox__detail'],
81
+ [/^blockers\/\d+$/, 'blockers__detail'],
71
82
  [/^analytics\/builder\/\d+$/, 'analytics__builder'],
72
83
  [/^builders\/\d+\/active-claims$/, 'builders__active-claims'],
73
84
  ];
@@ -134,7 +145,13 @@ app.use((req, res, next) => {
134
145
  console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
135
146
  return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
136
147
  }
137
- res.json(EMPTY ? emptied(fix) : fix);
148
+ let payload = EMPTY ? emptied(fix) : fix;
149
+ if (RANK_OVERRIDE && apiPath === 'me') {
150
+ payload = JSON.parse(JSON.stringify(payload));
151
+ if (payload.builder) payload.builder.rank = RANK_OVERRIDE;
152
+ else if ('rank' in payload) payload.rank = RANK_OVERRIDE;
153
+ }
154
+ res.json(payload);
138
155
  });
139
156
 
140
157
  // The hall pages, pre-auth-gate, through the real seams: boot-time stamped
@@ -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).
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes