@bongos/core 1.18.19 → 1.19.2

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 (251) hide show
  1. package/.bongos-core.json +514 -194
  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/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +63 -41
  10. package/clients/bongos-client/index.cjs +63 -41
  11. package/clients/bongos-client/index.d.ts +78 -44
  12. package/clients/bongos-client/index.mjs +63 -41
  13. package/docs/adr/0020-security-model-public-stub.md +1 -1
  14. package/docs/adr/0097-one-active-claim-per-session-and-worktree-binding.md +2 -0
  15. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +1 -1
  16. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +2 -1
  17. package/docs/adr/0155-adopt-private-repo-widen-oauth-scope.md +51 -0
  18. package/docs/adr/0156-user-set-display-name-wins.md +62 -0
  19. package/docs/adr/0157-archon-is-rank-and-identity-only.md +93 -0
  20. package/docs/adr/0158-choke-point-verdict-taxonomy.md +174 -0
  21. package/docs/adr/0159-merge-gate-reconciliation.md +88 -0
  22. package/docs/adr/0160-main-audit-repo-verifiable-caller.md +77 -0
  23. package/docs/adr/README.md +8 -0
  24. package/docs/api/openapi.json +1209 -233
  25. package/docs/api-reference.md +61 -46
  26. package/docs/architecture.md +1 -1
  27. package/docs/canonical-permissions.md +61 -16
  28. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  29. package/docs/file-map.md +4 -1
  30. package/docs/onboarding/diagrams/assertions.json +3 -3
  31. package/docs/recipes/core-release-pipeline.md +179 -0
  32. package/docs/recipes/packaging-the-core.md +2 -0
  33. package/docs/recipes/parallel-goal-waves.md +1 -3
  34. package/docs/recipes/private-npm-distribution.md +2 -0
  35. package/docs/recipes/upgrading-the-core.md +2 -0
  36. package/migrations/core_197_display_name_source.sql +56 -0
  37. package/migrations/core_204_rekey_active_claim_per_session_worktree.sql +77 -0
  38. package/modules/autonomy/routes/autonomy.js +1 -1
  39. package/modules/builder-settings/db.js +7 -1
  40. package/modules/dev-box/app/src/vendor/bongos-client.cjs +63 -41
  41. package/modules/dev-box/routes/box.js +11 -9
  42. package/modules/discord/auth.js +3 -1
  43. package/modules/discord/routes/discord.js +8 -2
  44. package/modules/discord/ship-broadcast.js +48 -1
  45. package/modules/governance/catalog.js +70 -20
  46. package/modules/governance/db.js +102 -0
  47. package/modules/governance/migrations/governance_006_version_create.sql +38 -0
  48. package/modules/governance/migrations/governance_007_delegate_archon_ops_to_metic.sql +72 -0
  49. package/modules/governance/routes/governance.js +235 -6
  50. package/modules/grading/grader-workers/index.js +18 -9
  51. package/modules/hall-ui/public/gate.js +6 -2
  52. package/modules/hall-ui/public/goals-page.js +6 -2
  53. package/modules/hall-ui/public/governance.css +100 -0
  54. package/modules/hall-ui/public/governance.html +77 -0
  55. package/modules/hall-ui/public/governance.js +353 -0
  56. package/modules/hall-ui/public/harbor.js +14 -8
  57. package/modules/hall-ui/public/instances-panel.js +3 -1
  58. package/modules/hall-ui/public/new-project-wizard.js +6 -0
  59. package/modules/hall-ui/public/people.html +51 -0
  60. package/modules/hall-ui/public/people.js +100 -0
  61. package/modules/hall-ui/public/profile.html +2 -1
  62. package/modules/hall-ui/public/profile.js +9 -3
  63. package/modules/hall-ui/public/sessions.js +4 -2
  64. package/modules/hall-ui/public/shell.js +28 -7
  65. package/modules/hall-ui/public/task-detail.js +6 -3
  66. package/modules/hall-ui/public/watch.js +17 -9
  67. package/modules/ideas/blockers.js +44 -7
  68. package/modules/ideas/routes/blockers.js +12 -7
  69. package/modules/ideas/routes/inbox.js +2 -2
  70. package/modules/lifecycle/db.js +84 -17
  71. package/modules/lifecycle/github-push.js +37 -10
  72. package/modules/lifecycle/publish-reconciler.js +128 -31
  73. package/modules/lifecycle/routes/claims.js +10 -4
  74. package/modules/lifecycle/routes/dependencies.js +2 -2
  75. package/modules/lifecycle/routes/done-when.js +4 -4
  76. package/modules/lifecycle/routes/gate-approvals.js +9 -3
  77. package/modules/lifecycle/routes/goals.js +5 -5
  78. package/modules/lifecycle/routes/tasks.js +16 -16
  79. package/modules/lifecycle/routes/versions.js +13 -2
  80. package/modules/lifecycle/ship-card.js +38 -7
  81. package/modules/memory/routes/memory.js +2 -2
  82. package/modules/onboarding/routes/access-requests.js +3 -3
  83. package/modules/platform-identity/routes/projects.js +10 -6
  84. package/modules/platform-identity/routes/scouting.js +2 -2
  85. package/modules/provisioning/migrations/provisioning_004_onboard_mode.sql +30 -0
  86. package/modules/provisioning/provisioning.js +24 -6
  87. package/modules/provisioning/routes/provisioning.js +24 -6
  88. package/modules/security/artifact-scan/aggregate.js +133 -0
  89. package/modules/security/artifact-scan/detectors/capabilities.js +53 -0
  90. package/modules/security/artifact-scan/detectors/config-obfuscation.js +41 -0
  91. package/modules/security/artifact-scan/detectors/credentials.js +112 -0
  92. package/modules/security/artifact-scan/detectors/dependencies.js +115 -0
  93. package/modules/security/artifact-scan/detectors/hooks.js +116 -0
  94. package/modules/security/artifact-scan/detectors/injection.js +118 -0
  95. package/modules/security/artifact-scan/detectors/mcp.js +102 -0
  96. package/modules/security/artifact-scan/detectors/network.js +67 -0
  97. package/modules/security/artifact-scan/detectors/npm-lifecycle.js +67 -0
  98. package/modules/security/artifact-scan/detectors/permissions.js +91 -0
  99. package/modules/security/artifact-scan/detectors/shared.js +219 -0
  100. package/modules/security/artifact-scan/fence-local.js +99 -0
  101. package/modules/security/artifact-scan/fetch.js +330 -0
  102. package/modules/security/artifact-scan/floor.js +319 -0
  103. package/modules/security/artifact-scan/limits.js +66 -0
  104. package/modules/security/artifact-scan/manifest.js +203 -0
  105. package/modules/security/artifact-scan/mirror.js +158 -0
  106. package/modules/security/artifact-scan/quarantine.js +149 -0
  107. package/modules/security/artifact-scan/rebuild-spec.js +120 -0
  108. package/modules/security/artifact-scan/report.js +155 -0
  109. package/modules/security/artifact-scan/sanitize.js +317 -0
  110. package/modules/security/migrations/security_001_tool_scans.sql +145 -0
  111. package/modules/security/module.json +2 -2
  112. package/modules/security/routes/reports.js +11 -7
  113. package/modules/security/routes/scans.js +115 -0
  114. package/modules/security/scan-store.js +214 -0
  115. package/modules/security/tests/aggregate.mjs +283 -0
  116. package/modules/security/tests/artifact_scan.mjs +591 -0
  117. package/modules/security/tests/floor.mjs +549 -0
  118. package/modules/security/tests/mirror.mjs +350 -0
  119. package/modules/security/tests/sanitize_rebuild.mjs +499 -0
  120. package/modules/security/tests/scan_store.mjs +250 -0
  121. package/modules/security/tests/tool_scans_migration.mjs +116 -0
  122. package/modules/sessions/routes/sessions.js +6 -5
  123. package/package-lock.json +2 -2
  124. package/package.json +1 -1
  125. package/public-landing/create.html +13 -3
  126. package/scripts/gds/abandon.js +1 -1
  127. package/scripts/gds/audit-authorship.sh +6 -0
  128. package/scripts/gds/box.js +1 -1
  129. package/scripts/gds/claim.js +15 -0
  130. package/scripts/gds/cli-lib.js +16 -4
  131. package/scripts/gds/gating.js +28 -13
  132. package/scripts/gds/gen-api-docs.js +1 -1
  133. package/scripts/gds/goal.js +234 -0
  134. package/scripts/gds/init.js +70 -15
  135. package/scripts/gds/main-audit.js +80 -11
  136. package/scripts/gds/mirror-redact.js +6 -0
  137. package/scripts/gds/newcomer-floor.js +2 -2
  138. package/scripts/gds/provision.js +230 -94
  139. package/scripts/gds/run-unit-tests.js +9 -1
  140. package/scripts/gds/sandbox-stage.js +66 -9
  141. package/scripts/gds/search-ingest-md.js +1 -0
  142. package/scripts/gds/sequence.js +67 -8
  143. package/scripts/gds/ship.js +183 -50
  144. package/scripts/hall-preview/server.js +1 -1
  145. package/scripts/migrate.sh +5 -5
  146. package/src/bongos/auth.js +129 -27
  147. package/src/bongos/db-kernel.js +66 -5
  148. package/src/bongos/db.js +6 -7
  149. package/src/bongos/github-repos.js +19 -7
  150. package/src/bongos/goal-scope-check.js +1 -1
  151. package/src/bongos/module-scope-map.js +3 -1
  152. package/src/bongos/permission-path-check.js +1 -1
  153. package/src/bongos/route-rank-check.js +73 -30
  154. package/src/bongos/routes/audit-log.js +4 -2
  155. package/src/bongos/routes/auth.js +8 -4
  156. package/src/bongos/routes/backup.js +7 -4
  157. package/src/bongos/routes/builders.js +23 -11
  158. package/src/bongos/routes/github.js +15 -4
  159. package/src/bongos/routes/llm-cache.js +5 -2
  160. package/src/bongos/routes/modules.js +9 -5
  161. package/src/bongos/routes/security.js +21 -12
  162. package/src/bongos/serve-internal.js +22 -7
  163. package/src/module-api.js +86 -13
  164. package/src/platform-server.js +14 -0
  165. package/tests/api_client.mjs +18 -2
  166. package/tests/api_path_404.mjs +3 -0
  167. package/tests/art_stage.mjs +2 -1
  168. package/tests/atlas.mjs +32 -4
  169. package/tests/auth_admission_gate.mjs +17 -5
  170. package/tests/auth_error_brand.mjs +41 -21
  171. package/tests/auth_resolve_failure.mjs +155 -0
  172. package/tests/blocker_link.mjs +88 -0
  173. package/tests/bongos_downloads.mjs +2 -1
  174. package/tests/box_dns_repoint.mjs +2 -0
  175. package/tests/box_ship_permissions.mjs +2 -0
  176. package/tests/box_terminal_ssrf.mjs +23 -17
  177. package/tests/claim_error_surface.mjs +23 -0
  178. package/tests/claude_materialize.mjs +4 -0
  179. package/tests/cli_box_scope.mjs +40 -0
  180. package/tests/cloud_bongos_pack.mjs +34 -4
  181. package/tests/conductor.mjs +2 -1
  182. package/tests/criterion_authoring_gate.mjs +28 -5
  183. package/tests/currency_label.mjs +10 -1
  184. package/tests/deploy_instance.mjs +2 -0
  185. package/tests/devbox_downloads.mjs +2 -1
  186. package/tests/display_name.mjs +95 -6
  187. package/tests/eviction_policy.mjs +2 -1
  188. package/tests/file_map.mjs +10 -0
  189. package/tests/first_admin_bootstrap.mjs +10 -2
  190. package/tests/first_run_engine_check.mjs +2 -1
  191. package/tests/fitness.mjs +26 -6
  192. package/tests/gate_approvals.mjs +15 -2
  193. package/tests/github_push_land_proof.mjs +50 -4
  194. package/tests/github_repos_helpers.mjs +30 -5
  195. package/tests/github_token_store.mjs +23 -0
  196. package/tests/goal_abuse_matrix.mjs +13 -3
  197. package/tests/goal_cli.mjs +89 -0
  198. package/tests/governance_abuse_matrix.mjs +386 -0
  199. package/tests/governance_fold.mjs +156 -0
  200. package/tests/governance_fold_bench.mjs +50 -0
  201. package/tests/governance_live_proof.mjs +201 -0
  202. package/tests/governance_page_gates.mjs +21 -9
  203. package/tests/governance_parity.mjs +11 -2
  204. package/tests/governance_require_permission.mjs +125 -1
  205. package/tests/governance_routes.mjs +334 -0
  206. package/tests/grader.mjs +7 -3
  207. package/tests/hall_nav.mjs +14 -4
  208. package/tests/helpers.mjs +73 -0
  209. package/tests/hook_core_resolve.mjs +2 -1
  210. package/tests/host_topology_skips.mjs +97 -0
  211. package/tests/infra_exec_paths.mjs +14 -4
  212. package/tests/init.mjs +128 -0
  213. package/tests/known_failing_ratchet.mjs +89 -0
  214. package/tests/lifecycle-card.mjs +2 -1
  215. package/tests/main_audit.mjs +72 -1
  216. package/tests/memory_isolation.mjs +56 -9
  217. package/tests/migrate_root_resolution.mjs +1 -1
  218. package/tests/mirror_redact.mjs +19 -0
  219. package/tests/module_api.mjs +3 -1
  220. package/tests/module_loader.mjs +4 -0
  221. package/tests/modules.mjs +35 -7
  222. package/tests/modules_route.mjs +29 -5
  223. package/tests/onboarding_observability.mjs +49 -11
  224. package/tests/onboarding_route_signals.mjs +1 -0
  225. package/tests/one_active_claim_per_session.mjs +80 -0
  226. package/tests/pre_compact.mjs +2 -1
  227. package/tests/preview_server.mjs +2 -1
  228. package/tests/profile_route.mjs +1 -0
  229. package/tests/provision.mjs +170 -0
  230. package/tests/provisioning_onboard_mode.mjs +180 -0
  231. package/tests/publish_manifest.mjs +16 -3
  232. package/tests/publish_reconciler.mjs +181 -10
  233. package/tests/rank_tier_single_source.mjs +205 -0
  234. package/tests/recall_hints.mjs +2 -1
  235. package/tests/repo_map.mjs +4 -0
  236. package/tests/route_permission_check.mjs +14 -5
  237. package/tests/rubric_fixtures.mjs +3 -0
  238. package/tests/run_routine_two_root.mjs +2 -0
  239. package/tests/sandbox_stage.mjs +69 -7
  240. package/tests/search_ingest.mjs +7 -0
  241. package/tests/security_docs.mjs +28 -6
  242. package/tests/sequence.mjs +64 -0
  243. package/tests/session-cards.mjs +2 -1
  244. package/tests/session_start_freshness.mjs +2 -1
  245. package/tests/ship_broadcast_server.mjs +63 -1
  246. package/tests/ship_card.mjs +66 -0
  247. package/tests/ship_ci_deploy.mjs +66 -1
  248. package/tests/ship_smoke_gate.mjs +76 -0
  249. package/tests/versions_route.mjs +15 -0
  250. package/tests/workflow_exec_paths.mjs +16 -4
  251. package/tests/worktree_guard.mjs +2 -1
@@ -56,18 +56,47 @@ const { branding, userAgent } = require('../branding');
56
56
 
57
57
  const SCOPE = 'read:user';
58
58
 
59
+ // The repo-acting scope the onboarding wizard opts into. `repo`, NOT `public_repo`.
60
+ //
61
+ // ADR 0143 §1 originally chose `public_repo` precisely to avoid the all-or-nothing
62
+ // `repo`, and named its own reversal path: "revisit to `repo` only if private
63
+ // support is needed". It is needed. `public_repo` grants write to PUBLIC repos
64
+ // only, so ADOPTING an existing PRIVATE repo dies at the provisioner's scaffold
65
+ // push with GitHub 403 "Write access to repository not granted" — observed live on
66
+ // lars589/hermeslines-marketing. ADR 0155 takes the reversal at the owner's
67
+ // explicit call, accepting the cost it names: a token we persist can now write to
68
+ // EVERY repo the user owns, not just their public ones.
69
+ const REPO_SCOPE = 'repo';
70
+
59
71
  // Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
60
72
  // and the CLI Device Flow ever need. The onboarding wizard
61
- // (public-landing/create.html), however, lists + creates the signed-in user's own
62
- // GitHub repos (task 1002099) — which `read:user` cannot do — so it OPTS IN to a
63
- // broader scope. We add `public_repo`, NOT the all-or-nothing `repo`: it still
64
- // lists/creates PUBLIC repos (the norm for open-source Cloud Bongos instances)
65
- // but shows a far gentler consent screen and shrinks the blast radius of the
66
- // token we then persist (ADR 0143). Only the wizard passes { repos:true }; every
67
- // other sign-in stays identity-only. Pure + exported so the exact scope string
68
- // (a security-critical value) is unit-testable.
73
+ // (public-landing/create.html), however, lists + creates + is pushed to by the
74
+ // provisioner on the signed-in user's own GitHub repos (task 1002099) — which
75
+ // `read:user` cannot do — so it OPTS IN to REPO_SCOPE. Only the wizard passes
76
+ // { repos:true }; every other sign-in stays identity-only. Pure + exported so the
77
+ // exact scope string (a security-critical value) is unit-testable.
69
78
  function scopeForWebFlow({ repos = false } = {}) {
70
- return repos ? `${SCOPE} public_repo` : SCOPE;
79
+ return repos ? `${SCOPE} ${REPO_SCOPE}` : SCOPE;
80
+ }
81
+
82
+ // Does a scope string GitHub ACTUALLY GRANTED still satisfy the repo surfaces?
83
+ //
84
+ // WHY THIS EXISTS. Widening scopeForWebFlow does NOT upgrade tokens already in
85
+ // `github_oauth_tokens` — a row minted before ADR 0155 carries `public_repo`, which
86
+ // is enough to list and create PUBLIC repos. So every wizard call would still 200
87
+ // while silently omitting the user's PRIVATE repos from the picker, and the failure
88
+ // would resurface far downstream at the provisioner's push. Callers check this and
89
+ // re-prompt (repo_scope_required) instead of degrading quietly.
90
+ //
91
+ // GitHub returns granted scopes comma-separated in its own order (e.g.
92
+ // "public_repo,read:user"), so parse rather than substring-match — `public_repo`
93
+ // CONTAINS "repo" and a naive match would accept exactly the stale token this
94
+ // guards against. `repo` implies `public_repo`, so `repo` alone is sufficient.
95
+ function grantedScopeCoversRepos(granted) {
96
+ return String(granted || '')
97
+ .split(/[,\s]+/)
98
+ .map((s) => s.trim())
99
+ .includes(REPO_SCOPE);
71
100
  }
72
101
 
73
102
  // In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
@@ -1263,7 +1292,23 @@ function sessionScope(...keys) {
1263
1292
  const allowBoxScope = sessionScope(...((sessionScopeFor('box') || { permissions: [] }).permissions));
1264
1293
 
1265
1294
  async function requireBuilder(req, res, next) {
1266
- const session = await resolveSession(req);
1295
+ let session;
1296
+ try {
1297
+ session = await resolveSession(req);
1298
+ } catch (err) {
1299
+ // REQUIRED, not defensive: Express 4 discards a middleware's returned promise
1300
+ // (Layer.handle_request wraps only synchronous throws), so an uncaught rejection
1301
+ // here reaches no handler — the request hangs AND the process dies. Every async
1302
+ // middleware mounted directly on a router must own its rejection.
1303
+ //
1304
+ // next(err) rather than res.fail(): the envelope handler already renders and logs
1305
+ // it, and inventing a code here would need an api-errors registration to avoid
1306
+ // classifying as 500. Denying is correct regardless — if the session cannot be
1307
+ // resolved, the caller's identity is unknown, so admitting is never an option.
1308
+ // runPageGate below holds the same guard; the two auth paths must not diverge.
1309
+ next(err);
1310
+ return;
1311
+ }
1267
1312
  if (!session) {
1268
1313
  res.fail('unauthenticated', 401);
1269
1314
  return;
@@ -1335,6 +1380,11 @@ async function requireBuilder(req, res, next) {
1335
1380
  // reach the write path (ADR 0026 §6C). Read fresh per request like rank.
1336
1381
  system_role: session.system_role ?? null,
1337
1382
  };
1383
+ // The session-load fold (task 1002556): raw grant keys the session query
1384
+ // carried, read as fresh as rank above. requirePermission feeds them to the
1385
+ // pure catalog-drop instead of a second round-trip; null (fold off, or a test
1386
+ // stub without the column) means the gate falls back to the DB resolve.
1387
+ req.rawPermissionKeys = Array.isArray(session.raw_permission_keys) ? session.raw_permission_keys : null;
1338
1388
  next();
1339
1389
  }
1340
1390
 
@@ -1373,9 +1423,12 @@ function signInWithReturn(req) {
1373
1423
  // R107 replaced each gate's hand-rolled rank test with the catalog key sitting at
1374
1424
  // that gate's EXISTING rank floor, so the audience is unchanged over the four live
1375
1425
  // ranks (catalog.RANK_ORDER):
1376
- // requireBuilderPage was "any session" floor xenos → page.view.public
1377
- // requireNonXenosPage was rank !== 'xenos' floor thetes → page.view.builder
1378
- // requireArchonPage was rank === 'archon' floor archon → page.view.governance
1426
+ // requireBuilderPage was "any session" floor xenos → page.view.public
1427
+ // requireNonXenosPage was rank !== 'xenos' floor thetes → page.view.builder
1428
+ // requireGovernancePage was rank === 'archon' floor archon → page.view.governance
1429
+ // That third gate was spelled `requireArchonPage` until ADR 0157 lowered
1430
+ // page.view.governance's floor archon→metic and the name stopped being true; the
1431
+ // floor now lives in modules/governance/catalog.js, not in the function's name.
1379
1432
  // The one accepted divergence is the one governance_002 already documents
1380
1433
  // (tests/governance_parity.mjs Part D): a builder on a reserved/divine rank holds
1381
1434
  // no seeded role, so they now fail a page gate a bare rank test would have
@@ -1384,14 +1437,16 @@ function signInWithReturn(req) {
1384
1437
  // `deniedTo` is NEVER the sign-in flow. A visitor on that branch already HAS a
1385
1438
  // session, so bouncing them into sign-in returns them to the same gate — an
1386
1439
  // infinite loop. Every destination below is an ungated hall page:
1387
- // - /builders the hall home. A signed-in non-Archon IS a citizen, just
1388
- // not the Ruling Hand, so send them home.
1440
+ // - /builders the hall home. A signed-in builder who lacks the page's
1441
+ // permission IS still a citizen, so send them home.
1389
1442
  // - /builders/not-ready the friendly notice for the orientation reading rooms;
1390
1443
  // it shows the rank ladder and a way back to the hall.
1391
1444
  const PAGE_GATES = Object.freeze({
1392
1445
  requireBuilderPage: Object.freeze({ permission: 'page.view.public', deniedTo: '/builders' }),
1393
1446
  requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
1394
- requireArchonPage: Object.freeze({ permission: 'page.view.governance', deniedTo: '/builders' }),
1447
+ requireGovernancePage: Object.freeze({ permission: 'page.view.governance', deniedTo: '/builders' }),
1448
+ requireGovernanceManagePage: Object.freeze({ permission: 'governance.manage', deniedTo: '/builders' }),
1449
+ requireProjectCuratePage: Object.freeze({ permission: 'project.curate', deniedTo: '/builders' }),
1395
1450
  });
1396
1451
 
1397
1452
  // A builder's effective permission set, or NULL when authority cannot be
@@ -1399,14 +1454,31 @@ const PAGE_GATES = Object.freeze({
1399
1454
  // threw). Every caller must read null as a DENIAL: admitting when the authority
1400
1455
  // system is missing would make disabling a module a privilege escalation — the
1401
1456
  // inversion ADR 0016 exists to prevent. Resolved per request, never memoised.
1402
- async function resolveHeldPermissions(builderId) {
1457
+ //
1458
+ // The session-load FOLD (task 1002556): when the caller's session row already
1459
+ // carried the raw grant keys (db-kernel's lookupSession aggregates them in the
1460
+ // same round-trip when governance is enabled), the catalog-drop runs pure via the
1461
+ // port — same instant, same request, one round-trip instead of two. This is a
1462
+ // query fold, not the cross-request memo ADR 0016 forbids. Anything unexpected —
1463
+ // no raw keys on the row, an older port without the pure half, a throw — falls
1464
+ // through to the DB resolve, so the fold can only ever be an optimization.
1465
+ async function resolveHeldPermissions(builderId, rawKeys) {
1403
1466
  if (builderId === null || builderId === undefined || builderId === '') return null;
1404
1467
  const governance = seams.resolveOptional('governance');
1405
1468
  if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
1469
+ if (Array.isArray(rawKeys) && typeof governance.effectivePermissions === 'function') {
1470
+ try {
1471
+ return new Set(governance.effectivePermissions(rawKeys));
1472
+ } catch (err) {
1473
+ console.error('[gds] permission fold failed:', err && err.message);
1474
+ // fall through to the DB resolve
1475
+ }
1476
+ }
1406
1477
  try {
1407
1478
  return new Set((await governance.resolveBuilderPermissions(builderId)) || []);
1408
1479
  } catch (err) {
1409
- console.error('[gds] permission resolve failed', err);
1480
+ // Message only — a resolver error can carry query text or row data.
1481
+ console.error('[gds] permission resolve failed:', err && err.message);
1410
1482
  return null;
1411
1483
  }
1412
1484
  }
@@ -1428,7 +1500,7 @@ async function runPageGate(name, req, res, next) {
1428
1500
  res.redirect(302, signInWithReturn(req));
1429
1501
  return;
1430
1502
  }
1431
- const held = await resolveHeldPermissions(session.builder_id);
1503
+ const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
1432
1504
  if (held === null || !held.has(permission)) {
1433
1505
  res.redirect(302, deniedTo);
1434
1506
  return;
@@ -1445,12 +1517,32 @@ async function requireBuilderPage(req, res, next) {
1445
1517
  }
1446
1518
 
1447
1519
  // The /watch monitoring surface, the /harbor dev-box page and the /gate page
1448
- // (#726, idea 257, task 1179) — Archon-only. Defense-in-depth: every data endpoint
1449
- // these pages call is independently gated server-side, so no cross-builder data
1450
- // leaks even if this gate were bypassed; it keeps a non-Archon from ever seeing
1451
- // the page shell.
1452
- async function requireArchonPage(req, res, next) {
1453
- return runPageGate('requireArchonPage', req, res, next);
1520
+ // (#726, idea 257, task 1179) — Metic and up, per `page.view.governance`'s catalog
1521
+ // floor. Named `requireArchonPage` until ADR 0157 lowered that floor archon→metic
1522
+ // (older ADRs still use the old name; this line is the trail). Defense-in-depth:
1523
+ // every data endpoint these pages call is independently gated server-side, so no
1524
+ // cross-builder data leaks even if this gate were bypassed; it keeps a builder
1525
+ // without the permission from ever seeing the page shell.
1526
+ async function requireGovernancePage(req, res, next) {
1527
+ return runPageGate('requireGovernancePage', req, res, next);
1528
+ }
1529
+
1530
+ // The /governance roles-and-permissions tab (BV1.R113) — gated on the
1531
+ // `governance.manage` ATOM itself (system, archon floor), the same key every
1532
+ // /governance/* data route checks, so the page layer can never drift wider than
1533
+ // the API it fronts. Defense-in-depth as above: the data endpoints are all
1534
+ // independently gated; this keeps the tab shell from rendering for anyone else.
1535
+ async function requireGovernanceManagePage(req, res, next) {
1536
+ return runPageGate('requireGovernanceManagePage', req, res, next);
1537
+ }
1538
+
1539
+ // The /people builder-directory page (task 1002577) — gated on the
1540
+ // `project.curate` ATOM, the same key its one data route (GET /scouting,
1541
+ // modules/platform-identity) checks, so the page layer can never drift wider
1542
+ // than the API it fronts. Spec R14 (goal 1000045) widens the atom's audience
1543
+ // later; this gate follows it automatically.
1544
+ async function requireProjectCuratePage(req, res, next) {
1545
+ return runPageGate('requireProjectCuratePage', req, res, next);
1454
1546
  }
1455
1547
 
1456
1548
  // The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
@@ -1590,6 +1682,11 @@ function requireRank(...allowed) {
1590
1682
  // UNCACHED — resolved on the request that needs it, exactly as requireRank reads
1591
1683
  // the live rank. No memo here and none in resolver.js (ADR 0016).
1592
1684
  //
1685
+ // The round-trip this costs (where requireRank compared in memory) IS the
1686
+ // guarantee: a revoked role stops working on the very next request. A hot route
1687
+ // should shed the gate, not the freshness — see task 1002556 for folding the
1688
+ // resolve into requireBuilder's existing session query.
1689
+ //
1593
1690
  // The 403 body mirrors requireRank's: `required` is the literal permission list
1594
1691
  // the gate names, and `held` is the subset of THOSE the builder actually holds —
1595
1692
  // or null when the set could not be resolved at all (the requireRank `actual ??
@@ -1606,7 +1703,9 @@ function requirePermission(...perms) {
1606
1703
  if (required.length === 0) return deny(null);
1607
1704
  // Shared with the page gates (R107): null = authority could not be established
1608
1705
  // (no port / malformed port / a throwing resolver) and is always a denial.
1609
- const held = await resolveHeldPermissions(builderId);
1706
+ // req.rawPermissionKeys rides the fold (task 1002556) when the session query
1707
+ // carried the grant keys; absent, the DB resolve runs exactly as before.
1708
+ const held = await resolveHeldPermissions(builderId, req.rawPermissionKeys);
1610
1709
  if (held === null) return deny(null);
1611
1710
  if (required.every((key) => held.has(key))) return next();
1612
1711
  return deny(required.filter((key) => held.has(key)));
@@ -1663,6 +1762,7 @@ module.exports = {
1663
1762
  pollDeviceFlow,
1664
1763
  buildAuthorizeUrl,
1665
1764
  scopeForWebFlow,
1765
+ grantedScopeCoversRepos,
1666
1766
  buildIdpAuthorizeUrl,
1667
1767
  idpAuthorizeUrl,
1668
1768
  completeWebFlow,
@@ -1683,8 +1783,10 @@ module.exports = {
1683
1783
  SESSION_SCOPES,
1684
1784
  requireBuilder,
1685
1785
  requireBuilderPage,
1686
- requireArchonPage,
1786
+ requireGovernancePage,
1787
+ requireGovernanceManagePage,
1687
1788
  requireNonXenosPage,
1789
+ requireProjectCuratePage,
1688
1790
  requireRank,
1689
1791
  rankMeetsThreshold,
1690
1792
  requirePermission,
@@ -12,9 +12,11 @@
12
12
  // (grading) lift their query clusters into modules/<key>/db.js the same way,
13
13
  // reaching these kernel helpers + `pool` through the doorway.
14
14
  //
15
- // Boundary: this file depends ONLY on ./pool (the singleton Postgres pool). It
16
- // must NEVER require ./db (that would invert the base→monolith direction and
17
- // risk a load cycle); db.js requires THIS file, not the reverse.
15
+ // Boundary: this file depends only on ./pool (the singleton Postgres pool) and
16
+ // ../module-seams (the kernel switchboard — kernel→kernel, added for the
17
+ // session-grants fold, task 1002556). It must NEVER require ./db (that would
18
+ // invert the base→monolith direction and risk a load cycle); db.js requires
19
+ // THIS file, not the reverse.
18
20
  //
19
21
  // db.js re-exports every name below, so its 23 importers + its own internal
20
22
  // uses are byte-identical — nothing downstream knows the slice moved.
@@ -22,6 +24,7 @@
22
24
  'use strict';
23
25
 
24
26
  const { pool } = require('./pool');
27
+ const seams = require('../module-seams');
25
28
 
26
29
  // withTx(fn, {pool}) — run fn(client) inside BEGIN/COMMIT, ROLLBACK+rethrow on
27
30
  // error, always release. The pool option preserves the deps.pool test seam.
@@ -115,6 +118,17 @@ async function insertAuditLog({
115
118
  // module can compare ranks without reaching core internals.
116
119
  const LIVE_RANK_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
117
120
 
121
+ // RANK_TIER — the ladder as a 1-based rank → tier map, DERIVED so it cannot
122
+ // drift (BV1.R108). THE canonical tier map: importable code reads this via the
123
+ // doorway (api.RANK_TIER). The few deliberately-local copies — dependency-free
124
+ // substrate (permission-path-check), standalone CLIs, hall browser statics, and
125
+ // src/bongos/db.js whose literal gate-review.js + diagram-facts.js parse as
126
+ // SOURCE TEXT — are each pinned to this map by tests/rank_tier_single_source.mjs,
127
+ // which also fails on any NEW parallel rank-tier map or ladder literal.
128
+ const RANK_TIER = Object.freeze(
129
+ Object.fromEntries(LIVE_RANK_LADDER.map((rank, i) => [rank, i + 1])),
130
+ );
131
+
118
132
  // RANKS — the FULL historical rank vocabulary, ordered high→low (lower index =
119
133
  // higher authority). ORDER IS LOAD-BEARING: setBuilderRank + auth's requireRank
120
134
  // threshold both use RANKS.indexOf to compare authority. A kernel constant
@@ -132,13 +146,23 @@ const RANKS = [
132
146
  // `pool`; db.js re-exports every name so its other importers are unchanged.
133
147
 
134
148
  // upsertBuilder — create or update the builder row for a GitHub identity.
149
+ //
150
+ // This runs on EVERY sign-in, not just at account creation, so the conflict
151
+ // branch is a re-sync from GitHub — which used to clobber a builder's
152
+ // self-chosen display name on their next authentication (task 1002568). The
153
+ // display_name CASE below is the "your name wins, always" guard: GitHub keeps
154
+ // seeding and re-syncing the name for anyone who never renamed
155
+ // (display_name_source='github'), and never overwrites one the builder set
156
+ // themselves ('user', stamped by setBuilderDisplayName). See ADR 0156.
135
157
  async function upsertBuilder({ githubId, githubLogin, displayName, avatarUrl }) {
136
158
  const { rows } = await pool.query(
137
159
  `INSERT INTO builders (github_id, github_login, display_name, avatar_url)
138
160
  VALUES ($1, $2, $3, $4)
139
161
  ON CONFLICT (github_id) DO UPDATE
140
162
  SET github_login = EXCLUDED.github_login,
141
- display_name = EXCLUDED.display_name,
163
+ display_name = CASE WHEN builders.display_name_source = 'user'
164
+ THEN builders.display_name
165
+ ELSE EXCLUDED.display_name END,
142
166
  avatar_url = EXCLUDED.avatar_url
143
167
  RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, created_at, rank, preferred_disciplines`,
144
168
  [String(githubId), githubLogin, displayName, avatarUrl ?? null]
@@ -234,12 +258,47 @@ async function createSession({ builderId, token, source, userAgent, expiresAt })
234
258
  // builder identity, and refresh-on-use (bump last_used_at + roll expiry forward,
235
259
  // fire-and-forget). Returns null for missing/revoked/expired. Read fresh per
236
260
  // request, never cached (ADR 0016).
261
+ //
262
+ // The governance FOLD (task 1002556): when the governance module is enabled, the
263
+ // same round-trip also aggregates the builder's raw permission grant keys, so
264
+ // requirePermission/runPageGate need no second query — one snapshot per request,
265
+ // still uncached (this is a query fold, not the cross-request memo ADR 0016
266
+ // forbids). Enablement is boot-static config, so the SQL shape is chosen once at
267
+ // load; on a governance-less instance the base query runs (the tables may not
268
+ // exist there) and permission gates keep failing closed via the missing port.
269
+ // The kernel stays MODULE-AGNOSTIC (ADR 0083 §Decision #4 — no module key may be
270
+ // hardcoded here): the fragment arrives through the capability-named seam
271
+ // 'session-grants-sql', which the authority module registers at boot alongside
272
+ // its resolver port. The fragment is that module's own SQL over its own tables —
273
+ // a trusted boot-time constant, never user input — and it must yield a single
274
+ // `raw_permission_keys` column against the alias `s` (builder_sessions). No
275
+ // provider (module disabled, or a boot path that never mounts routes) → the base
276
+ // query runs and permission gates keep failing closed via their missing port.
277
+ // Latched only once FOUND: registration order vs the first lookup is a boot
278
+ // race, and resolveOptional on a miss is a cheap map lookup.
279
+ //
280
+ // Shape guard: the fragment must be exactly one comma-led parenthesized scalar
281
+ // subquery aliased raw_permission_keys, with no statement separator. A module
282
+ // registering seams is in-process trusted code (it could reach the pool
283
+ // directly), so this guards MISREGISTRATION — a malformed or mis-aimed
284
+ // fragment degrades to the base query instead of corrupting the session SQL.
285
+ const FRAGMENT_SHAPE = /^,\s*\(SELECT[^;]*\)\s*AS raw_permission_keys\s*$/;
286
+ let SESSION_PERMS_SELECT = null;
287
+ function sessionPermsSelect() {
288
+ if (SESSION_PERMS_SELECT !== null) return SESSION_PERMS_SELECT;
289
+ const provider = seams.resolveOptional('session-grants-sql');
290
+ if (!provider || typeof provider.selectFragment !== 'string' || !FRAGMENT_SHAPE.test(provider.selectFragment)) {
291
+ return '';
292
+ }
293
+ SESSION_PERMS_SELECT = provider.selectFragment;
294
+ return SESSION_PERMS_SELECT;
295
+ }
237
296
  async function lookupSession(token) {
238
297
  if (!token) return null;
239
298
  const { rows } = await pool.query(
240
299
  `SELECT s.id AS session_id, s.builder_id, s.expires_at, s.revoked_at, s.source,
241
300
  b.github_id, b.github_login, b.display_name, b.avatar_url, b.total_credits,
242
- b.rank, b.status, b.system_role, b.anthropic_user_id
301
+ b.rank, b.status, b.system_role, b.anthropic_user_id${sessionPermsSelect()}
243
302
  FROM builder_sessions s
244
303
  JOIN builders b ON b.id = s.builder_id
245
304
  WHERE s.token = $1`,
@@ -263,10 +322,12 @@ async function lookupSession(token) {
263
322
 
264
323
  module.exports = {
265
324
  withTx,
325
+ sessionPermsSelect,
266
326
  getBuilderById,
267
327
  listActiveBuilders,
268
328
  insertAuditLog,
269
329
  LIVE_RANK_LADDER,
330
+ RANK_TIER,
270
331
  RANKS,
271
332
  SESSION_TTL_MS,
272
333
  upsertBuilder,
package/src/bongos/db.js CHANGED
@@ -298,13 +298,12 @@ async function deleteGithubToken({ builderId, p = pool }) {
298
298
  // shipped." Pure decision helper is exported so tests/rank_gate.mjs can exercise
299
299
  // the threshold without a DB.
300
300
 
301
- // REQUIRES_RANK_TIER — the canonical rank → tier (1-based) map: the rank "brain".
302
- // MUST stay byte-identical to permission-path-check.js RANK_TIER and
303
- // modules/lifecycle/db.js REQUIRES_RANK_TIER (tests/permission_path.mjs guards the
304
- // drift; gate-review.js + diagram-facts.js parse this literal by regex, so keep it
305
- // an inline object literal). Restores the binding the LIVE_RANK_LADDER carve
306
- // (ADR 0102) left dangling — both singleRungPromotionError below and the
307
- // db.REQUIRES_RANK_TIER export depend on it.
301
+ // REQUIRES_RANK_TIER — the rank "brain" literal. The CANONICAL map is now
302
+ // db-kernel.RANK_TIER, derived from LIVE_RANK_LADDER (BV1.R108); this stays an
303
+ // inline object literal ONLY because gate-review.js (the root-of-trust region
304
+ // watch) and diagram-facts.js parse it as SOURCE TEXT, which a derived value
305
+ // would break. tests/rank_tier_single_source.mjs pins it byte-identical to the
306
+ // kernel map (and permission-path-check.js RANK_TIER to both — the #863 class).
308
307
  const REQUIRES_RANK_TIER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
309
308
 
310
309
  function singleRungPromotionError(oldRank, newRank) {
@@ -3,10 +3,11 @@
3
3
  //
4
4
  // Raw `fetch` against GitHub's REST API (the house style — see auth.js
5
5
  // fetchGithubUser; no octokit dependency). The user-to-server token these take is
6
- // the `public_repo`-scoped token the web callback captured and stored (db.js
7
- // github_oauth_tokens); callers pass the decrypted token in, this module never
8
- // touches storage. `fetchImpl` is injectable so tests can drive it with a mock and
9
- // never hit the network.
6
+ // the `repo`-scoped token the web callback captured and stored (db.js
7
+ // github_oauth_tokens; `repo` since ADR 0155 — `public_repo` could not write a
8
+ // private repo, so private repos were unadoptable); callers pass the decrypted
9
+ // token in, this module never touches storage. `fetchImpl` is injectable so tests
10
+ // can drive it with a mock and never hit the network.
10
11
 
11
12
  const { userAgent } = require('../branding');
12
13
 
@@ -39,6 +40,10 @@ function ghHeaders(token, extra = {}) {
39
40
  // `login/repo` and the org-OAuth-policy complexity is out of scope for v1),
40
41
  // most-recently-updated first, optionally filtered by a case-insensitive substring
41
42
  // `q` on the full name. Returns [{ full_name, name, owner, private }].
43
+ //
44
+ // No `visibility` filter: under `repo` (ADR 0155) GitHub returns the user's PRIVATE
45
+ // repos here too, which is the point — they are adoptable now. That is what lights
46
+ // up the picker's long-dormant "Private" badge (public-landing/create.html).
42
47
  async function listUserRepos(token, { q = '', fetchImpl = fetch } = {}) {
43
48
  const out = [];
44
49
  for (let page = 1; page <= MAX_PAGES; page++) {
@@ -68,9 +73,16 @@ async function listUserRepos(token, { q = '', fetchImpl = fetch } = {}) {
68
73
 
69
74
  // Create an EMPTY repo in the signed-in user's personal account. auto_init:false =
70
75
  // no README/first commit (exactly the "leave it completely empty" the manual step
71
- // demanded); private:false because we hold only `public_repo`. Returns
72
- // { full_name, html_url }. A 422 (name already exists / invalid) becomes a typed
73
- // `name_taken` error the route maps to a friendly 409 rather than an opaque 500.
76
+ // demanded).
77
+ //
78
+ // private:false is now a PRODUCT choice, not a credential limit: since ADR 0155 the
79
+ // token holds `repo` and COULD create a private repo, but a Cloud Bongos instance is
80
+ // open-source / capture-resistant by design, so a repo WE create defaults to public.
81
+ // A user who wants a private instance adopts a private repo they made themselves.
82
+ // Deliberately not a parameter — nothing should be able to flip this implicitly.
83
+ //
84
+ // Returns { full_name, html_url }. A 422 (name already exists / invalid) becomes a
85
+ // typed `name_taken` error the route maps to a friendly 409 rather than an opaque 500.
74
86
  async function createUserRepo(token, { name, fetchImpl = fetch } = {}) {
75
87
  const res = await fetchImpl(`${GH_API}/user/repos`, {
76
88
  method: 'POST',
@@ -110,7 +110,7 @@ function check(goalScopeModules, touches, builderRank) {
110
110
  if (!prot.ok) {
111
111
  const hits = prot.protected_hits.map((h) => h.file);
112
112
  return deny('protected_path',
113
- `Builder rank '${builderRank}' is below Metic and may not author a task touching protected path(s): ${hits.join(', ')} (ADR 0016, SEC #863). An Archon must own this work.`,
113
+ `Builder rank '${builderRank}' is below Metic and may not author a task touching protected path(s): ${hits.join(', ')} (ADR 0016, SEC #863). A Metic+ builder must own this work.`,
114
114
  { protected_hits: prot.protected_hits });
115
115
  }
116
116
 
@@ -215,7 +215,9 @@ const MODULE_GLOBS = {
215
215
  // provisioning / bring-up work (BV1.R91, goal 1000051 C2), not just modules/dev-box/.
216
216
  // The box BRING-UP scripts (infra/box-*.sh — box-source-fetch, heartbeat, terminal
217
217
  // units) are INSTANCE-VENDORED (ADR 0144; not tracked in core), so they are not
218
- // listed here yet — they come under this key when de-vendored (task 1002243).
218
+ // listed here yet — they come under this key when de-vendored. (ADR 0150 /
219
+ // task 1002386 made box.js RESOLVE infra/ from the instance root and fail
220
+ // loudly when it is missing; de-vendoring infra/ into core is still open.)
219
221
  'dev-box': [
220
222
  'modules/dev-box/', 'devbox-app/',
221
223
  'scripts/gds/box.js', 'scripts/gds/box-auth-check.js', 'scripts/gds/box-connect-lib.js',
@@ -223,7 +223,7 @@ function checkPermissionPaths({ changedFiles, builderRank } = {}) {
223
223
  `${h.file} is a permission-sensitive surface (matches \`${h.glob}\`). `
224
224
  + `Builder rank '${builderRank}' is below ${floorLabel} and may not modify the rank / permission / `
225
225
  + `ship-grade / deploy / migration machinery (ADR 0016, SEC #863). `
226
- + `If this change is genuinely needed, an Archon must own it.`,
226
+ + `If this change is genuinely needed, a ${floorLabel}+ builder must own it.`,
227
227
  });
228
228
  }
229
229
  return {