@bongos/core 1.18.19 → 1.19.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 (259) hide show
  1. package/.bongos-core.json +547 -197
  2. package/.claude/skills/builder-box/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +1 -1
  4. package/.claude/skills/goal-create/SKILL.md +1 -1
  5. package/.claude/skills/idea-triage/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +5 -5
  7. package/.claude/skills/scan-before-install/SKILL.md +165 -0
  8. package/bin/bongos.js +33 -7
  9. package/clients/bongos-client/README.md +1 -1
  10. package/clients/bongos-client/bongos-client.global.js +63 -41
  11. package/clients/bongos-client/index.cjs +63 -41
  12. package/clients/bongos-client/index.d.ts +78 -44
  13. package/clients/bongos-client/index.mjs +63 -41
  14. package/docs/adr/0020-security-model-public-stub.md +1 -1
  15. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  16. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +1 -1
  17. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  18. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  19. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  20. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  21. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  22. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  23. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  24. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  25. package/docs/adr/0161-publish-on-merge.md +34 -0
  26. package/docs/adr/README.md +9 -0
  27. package/docs/api/openapi.json +1209 -233
  28. package/docs/api-reference.md +61 -46
  29. package/docs/architecture.md +1 -1
  30. package/docs/canonical-permissions.md +61 -16
  31. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  32. package/docs/file-map.md +4 -1
  33. package/docs/onboarding/diagrams/assertions.json +3 -3
  34. package/docs/recipes/core-release-pipeline.md +181 -0
  35. package/docs/recipes/packaging-the-core.md +2 -0
  36. package/docs/recipes/parallel-goal-waves.md +1 -3
  37. package/docs/recipes/private-npm-distribution.md +6 -2
  38. package/docs/recipes/upgrading-the-core.md +2 -0
  39. package/migrations/core_197_display_name_source.sql +56 -0
  40. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  41. package/migrations/core_205_versions_track_default.sql +28 -0
  42. package/modules/autonomy/routes/autonomy.js +1 -1
  43. package/modules/builder-settings/db.js +7 -1
  44. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  45. package/modules/dev-box/routes/box.js +11 -9
  46. package/modules/discord/auth.js +3 -1
  47. package/modules/discord/routes/discord.js +8 -2
  48. package/modules/discord/ship-broadcast.js +48 -1
  49. package/modules/governance/catalog.js +70 -20
  50. package/modules/governance/db.js +102 -0
  51. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  52. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  53. package/modules/governance/routes/governance.js +235 -6
  54. package/modules/grading/grader-workers/index.js +18 -9
  55. package/modules/hall-ui/public/gate.js +6 -2
  56. package/modules/hall-ui/public/goals-page.js +6 -2
  57. package/modules/hall-ui/public/governance.css +100 -0
  58. package/modules/hall-ui/public/governance.html +77 -0
  59. package/modules/hall-ui/public/governance.js +353 -0
  60. package/modules/hall-ui/public/harbor.js +14 -8
  61. package/modules/hall-ui/public/instances-panel.js +3 -1
  62. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  63. package/modules/hall-ui/public/people.html +51 -0
  64. package/modules/hall-ui/public/people.js +100 -0
  65. package/modules/hall-ui/public/profile.html +2 -1
  66. package/modules/hall-ui/public/profile.js +9 -3
  67. package/modules/hall-ui/public/sessions.js +4 -2
  68. package/modules/hall-ui/public/shell.js +28 -7
  69. package/modules/hall-ui/public/task-detail.js +6 -3
  70. package/modules/hall-ui/public/watch.js +17 -9
  71. package/modules/ideas/blockers.js +44 -7
  72. package/modules/ideas/routes/blockers.js +12 -7
  73. package/modules/ideas/routes/inbox.js +2 -2
  74. package/modules/lifecycle/db.js +84 -17
  75. package/modules/lifecycle/github-push.js +37 -10
  76. package/modules/lifecycle/publish-reconciler.js +128 -31
  77. package/modules/lifecycle/routes/claims.js +10 -4
  78. package/modules/lifecycle/routes/dependencies.js +2 -2
  79. package/modules/lifecycle/routes/done-when.js +4 -4
  80. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  81. package/modules/lifecycle/routes/goals.js +5 -5
  82. package/modules/lifecycle/routes/tasks.js +99 -69
  83. package/modules/lifecycle/routes/versions.js +13 -2
  84. package/modules/lifecycle/ship-card.js +38 -7
  85. package/modules/memory/routes/memory.js +2 -2
  86. package/modules/onboarding/routes/access-requests.js +3 -3
  87. package/modules/platform-identity/routes/projects.js +10 -6
  88. package/modules/platform-identity/routes/scouting.js +2 -2
  89. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  90. package/modules/provisioning/provisioning.js +24 -6
  91. package/modules/provisioning/routes/provisioning.js +24 -6
  92. package/modules/security/artifact-scan/aggregate.js +133 -0
  93. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  94. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  95. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  96. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  97. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  98. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  99. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  100. package/modules/security/artifact-scan/detectors/network.js +67 -0
  101. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  102. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  103. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  104. package/modules/security/artifact-scan/fence-local.js +99 -0
  105. package/modules/security/artifact-scan/fetch.js +330 -0
  106. package/modules/security/artifact-scan/floor.js +319 -0
  107. package/modules/security/artifact-scan/limits.js +66 -0
  108. package/modules/security/artifact-scan/manifest.js +203 -0
  109. package/modules/security/artifact-scan/mirror.js +158 -0
  110. package/modules/security/artifact-scan/quarantine.js +149 -0
  111. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  112. package/modules/security/artifact-scan/report.js +155 -0
  113. package/modules/security/artifact-scan/sanitize.js +317 -0
  114. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  115. package/modules/security/module.json +2 -2
  116. package/modules/security/routes/reports.js +11 -7
  117. package/modules/security/routes/scans.js +115 -0
  118. package/modules/security/scan-store.js +214 -0
  119. package/modules/security/tests/aggregate.mjs +283 -0
  120. package/modules/security/tests/artifact_scan.mjs +591 -0
  121. package/modules/security/tests/floor.mjs +549 -0
  122. package/modules/security/tests/mirror.mjs +350 -0
  123. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  124. package/modules/security/tests/scan_store.mjs +250 -0
  125. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  126. package/modules/sessions/routes/sessions.js +6 -5
  127. package/package-lock.json +2 -2
  128. package/package.json +1 -1
  129. package/public-landing/create.html +13 -3
  130. package/scripts/gds/abandon.js +1 -1
  131. package/scripts/gds/audit-authorship.sh +6 -0
  132. package/scripts/gds/box.js +1 -1
  133. package/scripts/gds/bump-version.js +76 -0
  134. package/scripts/gds/claim.js +15 -0
  135. package/scripts/gds/cli-lib.js +16 -4
  136. package/scripts/gds/gating.js +28 -13
  137. package/scripts/gds/gen-api-docs.js +1 -1
  138. package/scripts/gds/goal.js +234 -0
  139. package/scripts/gds/init.js +77 -15
  140. package/scripts/gds/main-audit.js +80 -11
  141. package/scripts/gds/mirror-redact.js +6 -0
  142. package/scripts/gds/newcomer-floor.js +2 -2
  143. package/scripts/gds/provision.js +230 -94
  144. package/scripts/gds/run-unit-tests.js +9 -1
  145. package/scripts/gds/sandbox-stage.js +66 -9
  146. package/scripts/gds/search-ingest-md.js +1 -0
  147. package/scripts/gds/sequence.js +67 -8
  148. package/scripts/gds/ship.js +183 -50
  149. package/scripts/hall-preview/server.js +1 -1
  150. package/scripts/migrate.sh +5 -5
  151. package/src/bongos/auth.js +129 -27
  152. package/src/bongos/db-kernel.js +66 -5
  153. package/src/bongos/db.js +6 -7
  154. package/src/bongos/github-repos.js +19 -7
  155. package/src/bongos/goal-scope-check.js +1 -1
  156. package/src/bongos/module-scope-map.js +3 -1
  157. package/src/bongos/permission-path-check.js +1 -1
  158. package/src/bongos/route-rank-check.js +73 -30
  159. package/src/bongos/routes/audit-log.js +4 -2
  160. package/src/bongos/routes/auth.js +8 -4
  161. package/src/bongos/routes/backup.js +7 -4
  162. package/src/bongos/routes/builders.js +23 -11
  163. package/src/bongos/routes/github.js +15 -4
  164. package/src/bongos/routes/llm-cache.js +5 -2
  165. package/src/bongos/routes/modules.js +9 -5
  166. package/src/bongos/routes/security.js +21 -12
  167. package/src/bongos/serve-internal.js +22 -7
  168. package/src/module-api.js +96 -13
  169. package/src/platform-server.js +14 -0
  170. package/tests/api_client.mjs +18 -2
  171. package/tests/api_path_404.mjs +3 -0
  172. package/tests/art_stage.mjs +2 -1
  173. package/tests/atlas.mjs +32 -4
  174. package/tests/auth_admission_gate.mjs +17 -5
  175. package/tests/auth_error_brand.mjs +41 -21
  176. package/tests/auth_resolve_failure.mjs +155 -0
  177. package/tests/blocker_link.mjs +88 -0
  178. package/tests/bongos_cli.mjs +91 -0
  179. package/tests/bongos_downloads.mjs +2 -1
  180. package/tests/box_dns_repoint.mjs +2 -0
  181. package/tests/box_ship_permissions.mjs +2 -0
  182. package/tests/box_terminal_ssrf.mjs +23 -17
  183. package/tests/bump_version.mjs +71 -0
  184. package/tests/claim_error_surface.mjs +23 -0
  185. package/tests/claude_materialize.mjs +4 -0
  186. package/tests/cli_box_scope.mjs +40 -0
  187. package/tests/cloud_bongos_pack.mjs +34 -4
  188. package/tests/conductor.mjs +2 -1
  189. package/tests/criterion_authoring_gate.mjs +28 -5
  190. package/tests/currency_label.mjs +10 -1
  191. package/tests/deploy_instance.mjs +2 -0
  192. package/tests/devbox_downloads.mjs +2 -1
  193. package/tests/display_name.mjs +95 -6
  194. package/tests/eviction_policy.mjs +2 -1
  195. package/tests/file_map.mjs +10 -0
  196. package/tests/first_admin_bootstrap.mjs +10 -2
  197. package/tests/first_run_engine_check.mjs +2 -1
  198. package/tests/fitness.mjs +26 -6
  199. package/tests/gate_approvals.mjs +15 -2
  200. package/tests/github_push_land_proof.mjs +50 -4
  201. package/tests/github_repos_helpers.mjs +30 -5
  202. package/tests/github_token_store.mjs +23 -0
  203. package/tests/goal_abuse_matrix.mjs +13 -3
  204. package/tests/goal_cli.mjs +89 -0
  205. package/tests/governance_abuse_matrix.mjs +386 -0
  206. package/tests/governance_fold.mjs +156 -0
  207. package/tests/governance_fold_bench.mjs +50 -0
  208. package/tests/governance_live_proof.mjs +201 -0
  209. package/tests/governance_page_gates.mjs +21 -9
  210. package/tests/governance_parity.mjs +11 -2
  211. package/tests/governance_require_permission.mjs +125 -1
  212. package/tests/governance_routes.mjs +334 -0
  213. package/tests/grader.mjs +7 -3
  214. package/tests/hall_nav.mjs +14 -4
  215. package/tests/helpers.mjs +73 -0
  216. package/tests/hook_core_resolve.mjs +2 -1
  217. package/tests/host_topology_skips.mjs +97 -0
  218. package/tests/infra_exec_paths.mjs +14 -4
  219. package/tests/init.mjs +147 -0
  220. package/tests/known_failing_ratchet.mjs +89 -0
  221. package/tests/lifecycle-card.mjs +2 -1
  222. package/tests/main_audit.mjs +72 -1
  223. package/tests/memory_isolation.mjs +56 -9
  224. package/tests/migrate_root_resolution.mjs +1 -1
  225. package/tests/mirror_redact.mjs +19 -0
  226. package/tests/module_api.mjs +3 -1
  227. package/tests/module_loader.mjs +4 -0
  228. package/tests/modules.mjs +35 -7
  229. package/tests/modules_route.mjs +29 -5
  230. package/tests/onboarding_observability.mjs +49 -11
  231. package/tests/onboarding_route_signals.mjs +1 -0
  232. package/tests/one_active_claim_per_session.mjs +80 -0
  233. package/tests/patch_gate_ordering.mjs +102 -0
  234. package/tests/pre_compact.mjs +2 -1
  235. package/tests/preview_server.mjs +2 -1
  236. package/tests/profile_route.mjs +1 -0
  237. package/tests/provision.mjs +170 -0
  238. package/tests/provisioning_onboard_mode.mjs +180 -0
  239. package/tests/publish_manifest.mjs +16 -3
  240. package/tests/publish_reconciler.mjs +181 -10
  241. package/tests/rank_tier_single_source.mjs +205 -0
  242. package/tests/recall_hints.mjs +2 -1
  243. package/tests/repo_map.mjs +4 -0
  244. package/tests/route_permission_check.mjs +14 -5
  245. package/tests/rubric_fixtures.mjs +3 -0
  246. package/tests/run_routine_two_root.mjs +2 -0
  247. package/tests/sandbox_stage.mjs +69 -7
  248. package/tests/search_ingest.mjs +7 -0
  249. package/tests/security_docs.mjs +28 -6
  250. package/tests/sequence.mjs +64 -0
  251. package/tests/session-cards.mjs +2 -1
  252. package/tests/session_start_freshness.mjs +2 -1
  253. package/tests/ship_broadcast_server.mjs +63 -1
  254. package/tests/ship_card.mjs +66 -0
  255. package/tests/ship_ci_deploy.mjs +66 -1
  256. package/tests/ship_smoke_gate.mjs +76 -0
  257. package/tests/versions_route.mjs +15 -0
  258. package/tests/workflow_exec_paths.mjs +16 -4
  259. package/tests/worktree_guard.mjs +2 -1
package/src/module-api.js CHANGED
@@ -348,20 +348,93 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
348
348
  // precisely because nothing compared live against main. The doorway surface
349
349
  // is byte-identical to 1.18.17 (governance is reached through the kernel
350
350
  // port, not a new export), so despite the size this is → PATCH. (task 1002521)
351
- // 1.18.19 — SINGLE-FIX patch cut from the core-v1.18.18 tag, not from main: core
352
- // migrations were silently skipped on any checkout whose absolute path
353
- // contains a space. `for f in $MIGRATIONS` word-split the newline-separated
354
- // list on IFS, fragmenting every path; the `[ -e "$f" ]` guard then dropped
355
- // all of them and schema_migrations stayed empty. The module loop is quoted,
356
- // so module migrations ran against an empty schema and the operator saw a
357
- // misleading `relation "achievements" does not exist` from modules/economy
358
- // instead of the real cause. Now read with a newline-safe herestring, and a
359
- // no-op guard makes the skip FATAL rather than silent. No doorway change, no
360
- // schema change, one script + its test → PATCH. Deliberately does NOT carry
361
- // the unreleased 1.19 tree on main, so a blocked instance can take the fix
362
- // alone. (task 1002611)
351
+ // 1.18.19 — NEVER PUBLISHED UNDER THIS NUMBER. This entry described a carrier bump
352
+ // that was never cut; the version 1.18.19 on the registry is something else
353
+ // entirely — a SINGLE-FIX patch published out-of-band on 2026-08-08 from a
354
+ // branch off the core-v1.18.18 tag (release/core-1.18.19), carrying only the
355
+ // space-in-path migration fix (`for f in $MIGRATIONS` word-split
356
+ // the migration list on IFS, so any checkout whose path contains a space
357
+ // applied ZERO core migrations, silently). It was cut off the TAG rather than
358
+ // off main precisely so a blocked instance could take that one fix without
359
+ // also taking the unreleased 1.19 tree. Everything the original note below
360
+ // describes is still unreleased and ships with 1.19.x — as does the migration
361
+ // fix itself, which is also on main. Original notes kept for the record:
362
+ // carrier bump for the 32 commits that landed on main between the
363
+ // 1.18.18 bump (e25c7da, 2026-08-03) and 2026-08-04. Headline: the
364
+ // security-tooling line — the reading-room mirror with on-disk scrub +
365
+ // fence and realpath confinement, a hardened fetch with quarantine and a
366
+ // deterministic manifest, the SSRF hole closed, and the durable
367
+ // `security_tool_scans` record + its Metic+-write/all-ranks-read route
368
+ // (tasks 1002542/1002543/1002546/1002547, heavily grader-iterated). Also:
369
+ // the core unit suite went green (task 1002470 — 57 failing files down to
370
+ // 3, and those 3 now sit behind an explicit ratchet that requires a
371
+ // justification and a named owner per entry, so a mute is no longer
372
+ // indistinguishable from a fix); the ship card can no longer report a
373
+ // bailed merge as landing (task 1002541); a user-set display name now
374
+ // survives sign-in (task 1002568) and the onboarding OAuth scope widened
375
+ // to `repo` so a private repo can be adopted; the hall profile shows the
376
+ // real reward instead of 0 (task 1002569); linking a blocker actually
377
+ // blocks the task (task 1002559); and the CLI box_scope detector was
378
+ // revived against the ADR 0116 envelope, which had silently dead-ended
379
+ // three call sites (task 1002162). The doorway surface is byte-identical
380
+ // to 1.18.18 — verified by diffing src/module-api.js across the range —
381
+ // so despite the volume this is → PATCH. (task 1002581)
382
+ // 1.19.0 — requirePermission re-exported (BV1.R105, ADR 0151). A module route
383
+ // gated on an authority ATOM needs the same doorway access to the gate
384
+ // that requireRank has had since 1.0.0; without it the R104-R107
385
+ // migration could reach core routes only. Additive → MINOR. (task 1001847)
386
+ // Landed after 1.18.19, so this bump carries that carrier's contents too;
387
+ // the MINOR is owed solely to the added export. Instances pinned to a
388
+ // patch-only update channel will not take this automatically.
389
+ // 1.19.1 — the release that actually carries 1.19.0. 1.19.0 was bumped by
390
+ // ad7a262 and never packaged, so ~104 non-merge commits sat on main
391
+ // reachable by no pin; this is the artifact that ships them. Headline:
392
+ // the governance permission system's second half (R110 roles/perms/
393
+ // assignments CRUD behind governance.manage, R113 the Roles nav +
394
+ // page gate, R114 the C6 live proof, ADR 0157 delegating the
395
+ // operational block from Archon to Metic, and the permission resolve
396
+ // folded into the session load); the scan-before-install security
397
+ // line (deterministic zero-LLM floor, verdict reducer, reading-room
398
+ // mirror with on-disk scrub + realpath confinement, the scan-record
399
+ // route, and the SSRF fix — tasks 1002544-1002548); core_204
400
+ // re-keying the one-active-claim index per (session, worktree) so
401
+ // parallel worktrees can hold bound claims; the hall People tab and
402
+ // governance page; and the goal-board CLI. The doorway surface is
403
+ // byte-identical to 1.19.0 — verified by diffing src/module-api.js
404
+ // across ad7a262..HEAD — so despite the volume this is → PATCH.
405
+ // Cut as a fresh number rather than releasing HEAD as 1.19.0 so the
406
+ // changelog cannot claim 1.19.0's one-line entry describes 104
407
+ // commits. Note 1.18.x → 1.19.1 crosses a minor: a patch-only
408
+ // channel will not take it unattended. (task 1002602)
409
+ // 1.19.2 — the init-blocker fixes: db.createVersion now sends the NOT NULL
410
+ // versions.track column (every POST /versions 500ed on a fresh DB,
411
+ // blocking bongos init outright — greenfield AND adopt), the route
412
+ // accepts + validates an optional `track`, init.js sends it explicitly,
413
+ // the 'instance look' kickoff task is kind:'decision' +
414
+ // discipline:'ideator' (kind:'ideator' violated tasks_kind_check and
415
+ // was silently dropped), and seed failures now report loudly with
416
+ // actual-vs-expected counts (task 1002623). Also carries the other
417
+ // unreleased commits since the 1.19.1 bump: the CI `unit` check +
418
+ // real CODEOWNERS/required-checks gating (tasks 1002610/1002611/
419
+ // 1002619, ADR 0159), the repo-verifiable main-audit caller (1002612/
420
+ // 1002618, ADR 0160), migrate.sh space-in-path fix, ship smoke-fail
421
+ // abort (1002605) + sandbox review-gate fix (1002607), strand
422
+ // notify-on-detect (1002606), reconciler out-of-band land (1002615),
423
+ // and the Metic-floor string sweep (1002604). The doorway surface is
424
+ // unchanged (an optional request field, no export change) → PATCH.
425
+ // (task 1002624)
426
+ // 1.19.3 — CLI/init ergonomics carrier: `bongos <verb> --help` is answered by
427
+ // the dispatcher instead of blocking on init's interview (1002625),
428
+ // `bongos init` defaults branding configDir from the product slug so
429
+ // instances stop sharing ~/.config/cloudbongos and evicting each
430
+ // other's CLI sessions (1002626), versions.track gains DB DEFAULT
431
+ // 'product' via core_205 so no insert path can reproduce the
432
+ // fresh-instance standup blocker (1002627). Also sweeps PATCH
433
+ // /tasks/:id gate atomicity (1002609) and the publish-on-merge lane
434
+ // (1002620, ADR 0161). git diff 1.19.2..main on this file was empty —
435
+ // the doorway surface is byte-identical → PATCH. (task 1002631)
363
436
  // ---------------------------------------------------------------------------
364
- const CORE_VERSION = '1.18.19'; // single-fix patch off the 1.18.18 tag: migrations skipped on a path with a space (task 1002611); see changelog above
437
+ const CORE_VERSION = '1.19.3'; // CLI/init ergonomics carrier (task 1002631); see changelog above
365
438
 
366
439
  // A namespaced logger so a module's log lines are attributable + consistent.
367
440
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -381,6 +454,13 @@ module.exports = {
381
454
  // routes; a module declares the rank it needs and never wires auth itself.
382
455
  requireBuilder: auth.requireBuilder,
383
456
  requireRank: auth.requireRank,
457
+ // requirePermission(...atoms) — the AUTHORITY-ATOM gate (ADR 0151), mounted
458
+ // AFTER requireBuilder exactly like requireRank. Async, AND-semantics,
459
+ // fail-closed; denies with `permission_forbidden` rather than
460
+ // `rank_forbidden`. The rank-role compat seed makes it admit exactly who
461
+ // the equivalent requireRank admitted, so a migrated module route is
462
+ // behaviour-identical (BV1.R105).
463
+ requirePermission: auth.requirePermission,
384
464
  rankMeetsThreshold: auth.rankMeetsThreshold,
385
465
  requireBfgPrincipal: auth.requireBfgPrincipal,
386
466
  // allowBoxScope — opt a route in to box-scoped (dev-box bootstrap) session
@@ -418,6 +498,9 @@ module.exports = {
418
498
  getBuilderById: (...a) => require('./bongos/db-kernel').getBuilderById(...a),
419
499
  insertAuditLog: (...a) => require('./bongos/db-kernel').insertAuditLog(...a),
420
500
  get LIVE_RANK_LADDER() { return require('./bongos/db-kernel').LIVE_RANK_LADDER; },
501
+ // The ladder as the canonical 1-based rank → tier map (BV1.R108) — modules
502
+ // derive tier comparisons from this instead of hand-copying the map.
503
+ get RANK_TIER() { return require('./bongos/db-kernel').RANK_TIER; },
421
504
  // --- per-builder secrets at rest (AES-256-GCM box) — a kernel trust
422
505
  // primitive; the full secretBox object is exposed so a module can call
423
506
  // isConfigured/encrypt/decrypt/provisionMasterKey without reaching core.
@@ -207,6 +207,20 @@ if (require.main === module) {
207
207
  moduleLoader.stopModulePollers().finally(() => process.exit(0));
208
208
  });
209
209
  }
210
+
211
+ // Last-resort backstop. Node >=22 defaults to --unhandled-rejections=throw, and
212
+ // Express 4 discards a middleware's returned promise — so before this, one rejected
213
+ // await in any async middleware terminated the single process and dropped every
214
+ // in-flight request. The real cure is that each such middleware catches its own
215
+ // rejection (requireBuilder / runPageGate now do); this exists so the NEXT one
216
+ // anybody forgets degrades into a logged error instead of an outage.
217
+ //
218
+ // Deliberately does NOT exit: this is one process serving every request, so staying
219
+ // up with a logged error strictly dominates dying. A rejection that leaves genuinely
220
+ // corrupt state is not a shape this codebase produces — the DB work is transactional.
221
+ process.on('unhandledRejection', (reason) => {
222
+ console.error('[platform] unhandled promise rejection (staying up; this is a BUG — the owning await needs its own catch):', reason);
223
+ });
210
224
  }
211
225
 
212
226
  module.exports = { buildPlatformApp, startBackgroundServices };
@@ -130,6 +130,10 @@ test('devbox vendored client is byte-identical to the canonical CJS build (run d
130
130
  // separately, ADR 0045), so scripts/vendor-client.js copies index.cjs into
131
131
  // devbox-app/src/vendor/. Guard that the vendored copy never drifts from the
132
132
  // canonical build — CI catches a forgotten `npm run vendor` after a spec change.
133
+ // devbox-app/ is instance content the neutral core does not carry (task 1002470).
134
+ // Absent APP => nothing to vendor into; absent VENDORED FILE inside a present app
135
+ // is the real "forgot to run vendor" drift this guards, and still fails.
136
+ if (!fs.existsSync(path.join(ROOT, 'devbox-app'))) return;
133
137
  const vendored = path.join(ROOT, 'devbox-app/src/vendor/bongos-client.cjs');
134
138
  if (!fs.existsSync(vendored)) throw new assert.AssertionError({ message: 'devbox-app/src/vendor/bongos-client.cjs missing — run `npm run vendor` in devbox-app/' });
135
139
  const canonical = fs.readFileSync(path.join(ROOT, 'clients/bongos-client/index.cjs'), 'utf8');
@@ -183,8 +187,20 @@ test('generated .d.ts types success responses (task 2061)', () => {
183
187
  // the shared pagination block, wired into paginated list responses
184
188
  assert.match(dts, /export interface PageInfo \{ limit: number; offset: number; total\?: number \}/, 'PageInfo emitted');
185
189
  assert.match(dts, /export interface Get\w*Response \{ [^}]*page: PageInfo\b/, 'a list response wires page: PageInfo');
186
- // a real response interface: keys concrete, values unknown (the shape is the win)
187
- assert.match(dts, /export interface PostClaimsResponse \{ claim: unknown; task: unknown; just_unlocked: unknown; overlap_warning: unknown \}/, 'response shape has concrete keys');
190
+ // A real response interface: keys concrete, values unknown (the shape is the win).
191
+ // Asserted as that PROPERTY rather than as one exact field list — the exact list went
192
+ // stale the moment POST /claims gained `reward` (task 1002469), even though the client
193
+ // had been correctly regenerated and the freshness check above was green (task 1002470).
194
+ const postClaims = /export interface PostClaimsResponse \{ ([^}]*) \}/.exec(dts);
195
+ assert.ok(postClaims, 'PostClaimsResponse emitted');
196
+ const members = postClaims[1].split(';').map((s) => s.trim()).filter(Boolean);
197
+ for (const key of ['claim', 'task', 'just_unlocked', 'overlap_warning']) {
198
+ assert.ok(members.includes(`${key}: unknown`), `response shape has concrete key '${key}': ${postClaims[1]}`);
199
+ }
200
+ // ...and EVERY member keeps that form, so a mistyped value still fails here.
201
+ for (const m of members) {
202
+ assert.match(m, /^[A-Za-z_$][\w$]*\??: unknown$/, `member '${m}' must be a concrete key with an unknown value`);
203
+ }
188
204
  // the { tasks } shorthand is captured
189
205
  assert.match(dts, /export interface GetTasksResponse \{ tasks: unknown \}/, 'shorthand key captured');
190
206
  assert.match(dts, /getTasks\(args\?: RequestArgs\): Promise<GetTasksResponse>;/, 'GET method returns its shape');
@@ -83,6 +83,9 @@ t('taskNotReadyHint: a backlog task names the promote route', () => {
83
83
  const h = taskNotReadyHint(953, 'backlog');
84
84
  assert.match(h, /POST \/api\/bongos\/tasks\/953\/promote/);
85
85
  assert.match(h, /backlog/);
86
+ // task.promote is metic-floored (ADR 0157) — the hint must not send builders to an Archon.
87
+ assert.match(h, /Metic\+/);
88
+ assert.doesNotMatch(h, /Archon/);
86
89
  });
87
90
 
88
91
  t('taskNotReadyHint: a non-backlog status is explained + still names promote', () => {
@@ -9,12 +9,13 @@
9
9
 
10
10
  import { strict as assert } from 'node:assert';
11
11
  import { createRequire } from 'node:module';
12
+ import { requireHostModule } from './helpers.mjs';
12
13
  import fs from 'node:fs';
13
14
  import os from 'node:os';
14
15
  import path from 'node:path';
15
16
 
16
17
  const require = createRequire(import.meta.url);
17
- const art = require('../scripts/gds/art-stage.js');
18
+ const art = requireHostModule(require, '../scripts/gds/art-stage.js');
18
19
 
19
20
  let passed = 0, failed = 0;
20
21
  function t(name, fn) { try { fn(); passed++; console.log(` PASS ${name}`); } catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); } }
package/tests/atlas.mjs CHANGED
@@ -11,6 +11,7 @@ import { test } from 'node:test';
11
11
  import { createRequire } from 'node:module';
12
12
  import path from 'node:path';
13
13
  import { fileURLToPath } from 'node:url';
14
+ import { onDiskModuleKeys } from './helpers.mjs';
14
15
 
15
16
  const require = createRequire(import.meta.url);
16
17
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -61,9 +62,36 @@ for (const view of ['context', 'modules']) {
61
62
  });
62
63
  }
63
64
 
64
- test('atlas modules view: the 7 real on-disk modules are all present', () => {
65
- const labels = new Set(VIEWS.modules.nodes.filter((n) => n.kind === 'module').map((n) => n.label));
66
- for (const key of ['game', 'dev-box', 'discord', 'art-pipeline', 'grading', 'memory', 'character-anim']) {
67
- assert.ok(labels.has(key), `module '${key}' is rendered (real on-disk module must never be dropped)`);
65
+ // The expectation is DERIVED FROM DISK via the shared helper, not hardcoded: the old
66
+ // 7-key roster named game / art-pipeline / character-anim (gone at the CB→OTB split)
67
+ // and silently missed governance / platform-identity, so it asserted a topology that no
68
+ // longer existed (tasks 1002449, 1002470). See onDiskModuleKeys() in helpers.mjs for why
69
+ // reading disk directly is what keeps this a real check rather than a tautology.
70
+
71
+ function assertEveryModuleRendered(renderedLabels, expectedKeys) {
72
+ for (const key of expectedKeys) {
73
+ assert.ok(renderedLabels.has(key), `module '${key}' is rendered (real on-disk module must never be dropped)`);
68
74
  }
75
+ }
76
+
77
+ test('atlas modules view: every real on-disk module is present', () => {
78
+ const expected = onDiskModuleKeys();
79
+ // Non-vacuity floor: a readdir that returned nothing would make the loop below
80
+ // pass while asserting nothing at all.
81
+ assert.ok(expected.length >= 5, `expected a real on-disk module roster, found ${expected.length}`);
82
+ const labels = new Set(VIEWS.modules.nodes.filter((n) => n.kind === 'module').map((n) => n.label));
83
+ assertEveryModuleRendered(labels, expected);
84
+ });
85
+
86
+ test('atlas modules view: the roster check CATCHES a dropped module (mutation check)', () => {
87
+ // Proves the retargeted assertion still FAILS when the thing it guards actually
88
+ // breaks — i.e. it was not made green by weakening it (task 1002470 done-when).
89
+ const expected = onDiskModuleKeys();
90
+ const mutated = new Set(expected);
91
+ mutated.delete(expected[0]); // simulate the generator silently dropping a real module
92
+ assert.throws(
93
+ () => assertEveryModuleRendered(mutated, expected),
94
+ /must never be dropped/,
95
+ 'a dropped on-disk module must still fail this check',
96
+ );
69
97
  });
@@ -107,11 +107,23 @@ console.log('\nfirstTimeAdmissionBlocked — first-admin bootstrap bypass (R62 /
107
107
 
108
108
  t('configured firstAdmin WITHOUT invite, enrollment OFF → ALLOWED (so the owner can be seated)', () => {
109
109
  withOpenEnrollmentEnv(undefined, () => {
110
- const fa = auth.firstAdminLogin();
111
- assert.ok(fa, 'the test branding pack configures a firstAdmin');
112
- assert.equal(auth.firstTimeAdmissionBlocked({ existing: null, approved: false, login: fa }), false);
113
- // case-insensitive match (GitHub logins compare case-insensitively)
114
- assert.equal(auth.firstTimeAdmissionBlocked({ existing: null, approved: false, login: fa.toUpperCase() }), false);
110
+ // The neutral pack sets firstAdmin: "" (an unclaimed instance has no owner yet),
111
+ // so this used to depend on an INSTANCE branding pack being present. Configure one
112
+ // through the documented env override instead — the bypass under test is
113
+ // "the configured firstAdmin gets in", regardless of where that config came
114
+ // from (task 1002470).
115
+ const prevFa = process.env.CLOUDBONGOS_FIRST_ADMIN;
116
+ process.env.CLOUDBONGOS_FIRST_ADMIN = auth.firstAdminLogin() || 'configured-owner';
117
+ try {
118
+ const fa = auth.firstAdminLogin();
119
+ assert.ok(fa, 'a firstAdmin is configured');
120
+ assert.equal(auth.firstTimeAdmissionBlocked({ existing: null, approved: false, login: fa }), false);
121
+ // case-insensitive match (GitHub logins compare case-insensitively)
122
+ assert.equal(auth.firstTimeAdmissionBlocked({ existing: null, approved: false, login: fa.toUpperCase() }), false);
123
+ } finally {
124
+ if (prevFa === undefined) delete process.env.CLOUDBONGOS_FIRST_ADMIN;
125
+ else process.env.CLOUDBONGOS_FIRST_ADMIN = prevFa;
126
+ }
115
127
  });
116
128
  });
117
129
 
@@ -11,10 +11,11 @@
11
11
  // values must win over the neutral code fallback.
12
12
  import assert from 'node:assert/strict';
13
13
  import { test } from 'node:test';
14
+ import { existsSync } from 'node:fs';
14
15
  import brandingMod from '../src/branding.js';
15
16
  import authMod from '../src/bongos/routes/auth.js';
16
17
 
17
- const { loadBranding } = brandingMod;
18
+ const { loadBranding, INSTANCE_PATH } = brandingMod;
18
19
  const { renderAuthErrorPage } = authMod;
19
20
 
20
21
  const KEYS = ['titleSuffix', 'epigraph', 'heading', 'subtitle', 'ctaLabel'];
@@ -38,13 +39,15 @@ test('authError: both packs carry a complete block, and the neutral pack is lore
38
39
  }
39
40
  }
40
41
 
41
- // OTB keeps its own flavor (regression lock — a future edit must not neutralize
42
- // OTB's OWN page while making the platform brand-aware).
43
- assert.equal(otb.heading, 'The Gate is Closed');
44
- assert.equal(otb.titleSuffix, 'the gate is closed');
45
- assert.match(otb.subtitle, /seek the Archon/);
46
- assert.match(otb.epigraph, /chronicles of the city/);
47
- assert.equal(otb.ctaLabel, 'Try the gate again');
42
+ // The instance keeps its OWN flavor (regression lock — a future edit must not
43
+ // neutralize an instance's own page while making the platform brand-aware). Only
44
+ // meaningful where an INSTANCE pack exists; the neutral core ships config/
45
+ // branding.neutral.json alone, and comparing its copy against OTB's lore made this
46
+ // assert one instance's content (task 1002470). The lore-free check below is the
47
+ // half that must hold everywhere, and it still runs unconditionally.
48
+ if (existsSync(INSTANCE_PATH)) {
49
+ assert.notDeepEqual(otb, neutral, 'an instance pack must be able to override the neutral authError copy');
50
+ }
48
51
 
49
52
  // The neutral pack — what cloudbongos.com actually renders — carries NO OTB lore.
50
53
  for (const k of KEYS) {
@@ -54,24 +57,41 @@ test('authError: both packs carry a complete block, and the neutral pack is lore
54
57
  }
55
58
  });
56
59
 
60
+ // The renderer HTML-escapes every interpolated value (XSS safety), so the page carries
61
+ // the ESCAPED form. Comparing raw pack strings worked only while the ambient pack
62
+ // happened to contain no escapable character; the neutral pack's subtitle has an
63
+ // apostrophe ("The sign-in didn't complete…") and rendered as `didn&#39;t`, so this
64
+ // test failed against the very pack cloudbongos.com serves (task 1002470). Mirrors
65
+ // escapeHtml() in src/bongos/routes/auth.js.
66
+ const escapeHtml = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({
67
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
68
+ }[c]));
69
+
57
70
  test('renderAuthErrorPage interpolates the branding pack, not hardcoded lore', () => {
58
- // Ambient branding in this checkout is the OTB pack, so the rendered page must
59
- // carry the OTB authError values AND the branded worldName.
60
- const otb = loadBranding({ env: {} });
61
- const a = otb.copy.authError;
71
+ // Whatever pack is ambient in this checkout, the rendered page must carry ITS
72
+ // authError values and ITS worldName — that is the pack-driven property.
73
+ const pack = loadBranding({ env: {} });
74
+ const a = pack.copy.authError;
62
75
  const html = renderAuthErrorPage('State mismatch', 'the detail line');
63
76
 
64
- assert.match(html, new RegExp(otb.identity.worldName)); // Amazonprimea, from the pack
65
- assert.ok(html.includes(a.heading), 'page heading comes from the pack');
66
- assert.ok(html.includes(a.epigraph), 'epigraph comes from the pack');
67
- assert.ok(html.includes(a.subtitle), 'sub-line comes from the pack');
68
- assert.ok(html.includes(a.ctaLabel), 'retry button label comes from the pack');
69
- assert.ok(html.includes(`${otb.identity.worldName} — ${a.titleSuffix}`), 'tab title = worldName + pack suffix');
77
+ assert.ok(html.includes(escapeHtml(pack.identity.worldName)), 'worldName comes from the pack');
78
+ assert.ok(html.includes(escapeHtml(a.heading)), 'page heading comes from the pack');
79
+ assert.ok(html.includes(escapeHtml(a.epigraph)), 'epigraph comes from the pack');
80
+ assert.ok(html.includes(escapeHtml(a.subtitle)), 'sub-line comes from the pack');
81
+ assert.ok(html.includes(escapeHtml(a.ctaLabel)), 'retry button label comes from the pack');
82
+ assert.ok(html.includes(`${escapeHtml(pack.identity.worldName)} — ${escapeHtml(a.titleSuffix)}`),
83
+ 'tab title = worldName + pack suffix');
70
84
  // The per-branch args still render (they are passed in, not pack-owned).
71
85
  assert.ok(html.includes('State mismatch') && html.includes('the detail line'));
72
86
 
73
87
  // The pack must have WON over the neutral code fallback — otherwise the render
74
- // wasn't actually pack-driven.
75
- assert.ok(!html.includes('— access —'), 'neutral fallback epigraph must not appear under the OTB pack');
76
- assert.ok(!html.includes('Try signing in again'), 'neutral fallback cta must not appear under the OTB pack');
88
+ // wasn't actually pack-driven. Only checkable when an INSTANCE pack is what's
89
+ // ambient; under the neutral pack those strings ARE the correct output.
90
+ const neutral = vanilla().copy.authError;
91
+ if (a.epigraph !== neutral.epigraph) {
92
+ assert.ok(!html.includes(escapeHtml(neutral.epigraph)), 'neutral fallback epigraph must not appear under an instance pack');
93
+ }
94
+ if (a.ctaLabel !== neutral.ctaLabel) {
95
+ assert.ok(!html.includes(escapeHtml(neutral.ctaLabel)), 'neutral fallback cta must not appear under an instance pack');
96
+ }
77
97
  });
@@ -0,0 +1,155 @@
1
+ // tests/auth_resolve_failure.mjs — a session-lookup failure must DENY, not crash.
2
+ //
3
+ // The defect (found by the 2026-07-30 adversarial review, survived refutation):
4
+ // `requireBuilder` awaited `resolveSession` with no try/catch. Express 4 discards a
5
+ // middleware's returned promise — `Layer.handle_request` wraps only SYNCHRONOUS
6
+ // throws — so a rejected lookup reached no error handler at all: the caller got no
7
+ // response, and node's default `--unhandled-rejections=throw` killed the single
8
+ // process, dropping every other in-flight request with it. A routine Postgres
9
+ // restart was a full outage instead of a burst of 500s.
10
+ //
11
+ // DB-free: the session lookup is stubbed to reject. These assertions are about
12
+ // CONTROL FLOW (does the rejection escape?), not about Postgres.
13
+ import assert from 'node:assert/strict';
14
+ import { test } from 'node:test';
15
+ import { createRequire } from 'node:module';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+
23
+ // A minimal res double: records what the gate did without needing express.
24
+ function makeRes() {
25
+ const res = { _status: null, _json: null, _redirect: null };
26
+ res.status = (s) => { res._status = s; return res; };
27
+ res.json = (b) => { res._json = b; return res; };
28
+ res.redirect = (s, u) => { res._redirect = [s, u]; return res; };
29
+ res.fail = (code, statusOrOpts) => {
30
+ res._status = typeof statusOrOpts === 'number' ? statusOrOpts : (statusOrOpts && statusOrOpts.status) || 500;
31
+ res._json = { error: { code } };
32
+ return res;
33
+ };
34
+ return res;
35
+ }
36
+
37
+ // Force resolveSession to THROW deterministically, with no DB and no stubbing
38
+ // framework: `req.headers` is the first thing it touches, so a throwing getter
39
+ // raises inside resolveSession exactly as a rejecting pool.query would.
40
+ //
41
+ // This matters. An earlier draft of this test passed a plain `req` and asserted
42
+ // "denied OR next(err)" — which, against a live Postgres in CI, would have been
43
+ // satisfied by the ordinary 401-for-an-invalid-token path and would NEVER have
44
+ // entered the catch this fix adds. A test whose meaning depends on whether a DB
45
+ // happens to be reachable is not a regression guard.
46
+ // Slice one function body by brace-balance rather than by looking for a blank line
47
+ // after the closing brace — the latter silently mis-slices the moment a function is
48
+ // moved next to another with no gap between them.
49
+ function sliceFn(src) {
50
+ const open = src.indexOf('{');
51
+ let depth = 0;
52
+ for (let i = open; i < src.length; i++) {
53
+ if (src[i] === '{') depth += 1;
54
+ else if (src[i] === '}') { depth -= 1; if (depth === 0) return src.slice(0, i + 1); }
55
+ }
56
+ throw new Error('unbalanced braces — could not slice the function body');
57
+ }
58
+
59
+ function throwingReq(message = 'simulated session-store failure') {
60
+ return {
61
+ get headers() { throw new Error(message); },
62
+ cookies: {},
63
+ get: () => undefined,
64
+ };
65
+ }
66
+
67
+ test('requireBuilder catches a THROWING session lookup instead of letting it escape', async () => {
68
+ const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
69
+ const res = makeRes();
70
+ let nexted = false; let nextErr;
71
+ await assert.doesNotReject(
72
+ () => auth.requireBuilder(throwingReq(), res, (e) => { nexted = true; nextErr = e; }),
73
+ 'the middleware must not reject — an escaping rejection is the outage',
74
+ );
75
+ assert.equal(nexted, true, 'the failure must reach next()');
76
+ assert.ok(nextErr instanceof Error, 'next() must receive the error, not be called cleanly');
77
+ assert.match(nextErr.message, /simulated session-store failure/,
78
+ 'the ORIGINAL error must be forwarded, not swallowed and replaced');
79
+ assert.equal(res._json, null, 'the gate must not write its own body — the envelope handler owns that');
80
+ });
81
+
82
+ test('a failing lookup NEVER falls through to the route handler', async () => {
83
+ const auth = require(path.join(ROOT, 'src', 'bongos', 'auth.js'));
84
+ const res = makeRes();
85
+ let cleanNext = false;
86
+ await auth.requireBuilder(throwingReq(), res, (e) => { if (e === undefined) cleanNext = true; });
87
+ assert.equal(cleanNext, false,
88
+ 'next() with no error would run the handler UNAUTHENTICATED — the one outcome that must be impossible');
89
+ });
90
+
91
+ test('AUDIT: every async middleware mounted directly on a router owns its rejection', () => {
92
+ // The enumeration, kept as an executable assertion rather than prose so it cannot
93
+ // rot: a NEW bare `async function require*` added later fails this test.
94
+ // requireBuilder — the API gate. Guarded here (BV1.R109-follow-up).
95
+ // requireBuilderPage \
96
+ // requireGovernancePage \
97
+ // requireGovernanceManagePage > all five delegate to runPageGate, guarded by BV1.R107.
98
+ // requireNonXenosPage / (the R113 roles-tab gate; the /people curate gate)
99
+ // requireProjectCuratePage /
100
+ // Anything else async and router-mounted must be added with its own try/catch.
101
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
102
+ const asyncMw = [...src.matchAll(/^async function (require\w+|allow\w+)\s*\(req, res, next\)/gm)].map((m) => m[1]);
103
+ assert.deepEqual(asyncMw.sort(), ['requireBuilder', 'requireBuilderPage', 'requireGovernanceManagePage', 'requireGovernancePage', 'requireNonXenosPage', 'requireProjectCuratePage'],
104
+ 'a new async router middleware appeared — give it a try/catch and add it here');
105
+ // The page gates must actually delegate (that is WHY they are safe).
106
+ for (const name of ['requireBuilderPage', 'requireGovernancePage', 'requireGovernanceManagePage', 'requireNonXenosPage', 'requireProjectCuratePage']) {
107
+ const fn = src.slice(src.indexOf(`async function ${name}`));
108
+ assert.match(sliceFn(fn), /runPageGate\(/, `${name} must delegate to the guarded runPageGate`);
109
+ }
110
+ });
111
+
112
+ test('the catch hands the error onward rather than swallowing it', () => {
113
+ // Swallowing would be worse than crashing: the request would hang with no
114
+ // response and no log. Assert the structure explicitly.
115
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
116
+ const fn = src.slice(src.indexOf('async function requireBuilder'));
117
+ const body = sliceFn(fn);
118
+ assert.match(body, /try\s*{[\s\S]*await resolveSession\(req\)[\s\S]*}\s*catch/,
119
+ 'requireBuilder must wrap resolveSession in try/catch');
120
+ assert.match(body, /catch[\s\S]*next\(err\)/, 'the catch must call next(err)');
121
+ // NB deliberately NOT asserting a console.error here: errorEnvelopeHandler already
122
+ // logs what next(err) hands it, so logging in the catch too would double-write.
123
+ });
124
+
125
+ test('a valid session still authenticates — the success path is unchanged', () => {
126
+ // The fix only ADDS an error path. Guard that the success path was not reshaped:
127
+ // the post-try body must still run the same status/scope checks in order.
128
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
129
+ const fn = src.slice(src.indexOf('async function requireBuilder'));
130
+ const body = sliceFn(fn);
131
+ assert.match(body, /if \(!session\)[\s\S]*unauthenticated/, 'no-session → 401 preserved');
132
+ assert.match(body, /session\.status === 'inactive'[\s\S]*builder_inactive/, 'inactive check preserved');
133
+ assert.match(body, /sessionScopeFor\(session\.source\)/, 'session-scope check preserved');
134
+ assert.ok(body.indexOf('unauthenticated') < body.indexOf("'inactive'"),
135
+ 'checks stay in their original order');
136
+ });
137
+
138
+ test('a process-level unhandledRejection backstop is registered and does not exit', () => {
139
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'platform-server.js'), 'utf8');
140
+ assert.match(src, /process\.on\('unhandledRejection'/, 'the backstop must exist');
141
+ const handler = src.slice(src.indexOf("process.on('unhandledRejection'"));
142
+ const body = handler.slice(0, handler.indexOf('});') + 3);
143
+ assert.match(body, /console\.error/, 'it must log');
144
+ assert.doesNotMatch(body, /process\.exit/,
145
+ 'it must NOT exit — one process serves every request, so staying up dominates dying');
146
+ });
147
+
148
+ test('the page gates already guard the same call — the two auth paths agree', () => {
149
+ // The original asymmetry (runPageGate guarded, requireBuilder bare) is what let
150
+ // this sit unnoticed. Pin that they now match so it cannot drift apart again.
151
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'auth.js'), 'utf8');
152
+ const page = src.slice(src.indexOf('async function runPageGate'));
153
+ assert.match(sliceFn(page), /try\s*{[\s\S]*await resolveSession\(req\)[\s\S]*}\s*catch/,
154
+ 'runPageGate must keep its guard');
155
+ });