@bongos/core 1.18.0 → 1.18.18

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 (553) hide show
  1. package/.bongos-core.json +1740 -640
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +5 -3
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +11 -2
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  27. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  28. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  29. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  30. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  31. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  32. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  33. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  34. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  35. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  36. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  37. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  38. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  39. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  40. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  41. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  42. package/docs/adr/README.md +15 -2
  43. package/docs/api/openapi.json +2648 -483
  44. package/docs/api-reference.md +73 -13
  45. package/docs/architecture.md +4 -2
  46. package/docs/branding-contract.md +3 -1
  47. package/docs/canonical-permissions.md +21 -8
  48. package/docs/file-map.md +15 -7
  49. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  50. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  51. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  52. package/docs/onboarding/diagrams/assertions.json +4 -7
  53. package/docs/onboarding/drachmae-and-karma.md +3 -3
  54. package/docs/onboarding/primer.md +17 -16
  55. package/docs/onboarding/slash-commands.md +124 -0
  56. package/docs/recipes/builder-box-lifecycle.md +23 -0
  57. package/docs/recipes/devbox-app-release.md +14 -4
  58. package/docs/recipes/instance-first-run.md +16 -0
  59. package/docs/recipes/local-dev.md +81 -0
  60. package/docs/recipes/ops-gotchas.md +19 -1
  61. package/docs/recipes/overnight-builder.md +1 -1
  62. package/docs/recipes/packaging-the-core.md +13 -0
  63. package/docs/recipes/parallel-goal-waves.md +174 -0
  64. package/docs/recipes/ship-and-regrade.md +2 -2
  65. package/docs/recipes/standalone-live-docs.md +48 -0
  66. package/docs/recipes/upgrading-the-core.md +5 -2
  67. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  68. package/migrations/003_pms.sql +6 -7
  69. package/migrations/006_pms_v2.sql +3 -31
  70. package/migrations/039_newcomer_friendly_column.sql +5 -0
  71. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  72. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  73. package/migrations/core_193_task_needs_rebase.sql +28 -0
  74. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  75. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  76. package/migrations/core_196_session_reward_topup.sql +69 -0
  77. package/modules/autonomy/pollers/routine-timers.js +9 -8
  78. package/modules/builder-settings/wandering-prefs.js +61 -18
  79. package/modules/dev-box/app/README.md +73 -0
  80. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  81. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  82. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  83. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  84. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  85. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  86. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  87. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  88. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  89. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  90. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  91. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  92. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  93. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  94. package/modules/dev-box/app/build-resources/icon.png +0 -0
  95. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  96. package/modules/dev-box/app/electron-builder.yml +95 -0
  97. package/modules/dev-box/app/package-lock.json +5291 -0
  98. package/modules/dev-box/app/package.json +30 -0
  99. package/modules/dev-box/app/renderer/app.js +319 -0
  100. package/modules/dev-box/app/renderer/index.html +45 -0
  101. package/modules/dev-box/app/renderer/styles.css +246 -0
  102. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  103. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  104. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  105. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  106. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  107. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  108. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  109. package/modules/dev-box/app/src/box-controller.js +287 -0
  110. package/modules/dev-box/app/src/brand.generated.json +13 -0
  111. package/modules/dev-box/app/src/brand.js +16 -0
  112. package/modules/dev-box/app/src/config.js +19 -0
  113. package/modules/dev-box/app/src/gds-client.js +90 -0
  114. package/modules/dev-box/app/src/main.js +610 -0
  115. package/modules/dev-box/app/src/pairing.js +57 -0
  116. package/modules/dev-box/app/src/preload.js +29 -0
  117. package/modules/dev-box/app/src/session-store.js +58 -0
  118. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  119. package/modules/dev-box/app/src/task-picker.js +70 -0
  120. package/modules/dev-box/app/src/tray.js +99 -0
  121. package/modules/dev-box/app/src/updater.js +55 -0
  122. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  123. package/modules/dev-box/app/test/unit.mjs +526 -0
  124. package/modules/dev-box/box-access.js +121 -4
  125. package/modules/dev-box/boxes.js +20 -0
  126. package/modules/dev-box/module.json +1 -1
  127. package/modules/dev-box/routes/box.js +87 -3
  128. package/modules/economy/credits.js +116 -25
  129. package/modules/economy/reward.js +8 -0
  130. package/modules/economy/streak.js +2 -1
  131. package/modules/governance/CLAUDE.md +15 -0
  132. package/modules/governance/catalog.js +419 -0
  133. package/modules/governance/config.js +174 -0
  134. package/modules/governance/db.js +125 -0
  135. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  136. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  137. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  138. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  139. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  140. package/modules/governance/module.json +10 -0
  141. package/modules/governance/protected-surfaces.js +88 -0
  142. package/modules/governance/protected-surfaces.json +80 -0
  143. package/modules/governance/rank-roles.js +83 -0
  144. package/modules/governance/resolver.js +221 -0
  145. package/modules/governance/routes/governance.js +89 -0
  146. package/modules/governance/routes/role-sync.js +49 -0
  147. package/modules/governance/session-scopes.js +127 -0
  148. package/modules/governance/session-scopes.json +38 -0
  149. package/modules/hall-ui/public/atlas.css +117 -0
  150. package/modules/hall-ui/public/atlas.html +3 -114
  151. package/modules/hall-ui/public/brand-apply.js +34 -6
  152. package/modules/hall-ui/public/builders.js +187 -219
  153. package/modules/hall-ui/public/diagrams.html +2 -0
  154. package/modules/hall-ui/public/dom-utils.js +27 -0
  155. package/modules/hall-ui/public/drachmae.html +2 -0
  156. package/modules/hall-ui/public/drachmae.js +54 -1
  157. package/modules/hall-ui/public/gate.css +98 -0
  158. package/modules/hall-ui/public/gate.html +3 -95
  159. package/modules/hall-ui/public/goal-inbox.js +4 -1
  160. package/modules/hall-ui/public/goals-page.js +626 -114
  161. package/modules/hall-ui/public/goals.css +79 -1
  162. package/modules/hall-ui/public/goals.html +38 -11
  163. package/modules/hall-ui/public/hall-widgets.js +62 -52
  164. package/modules/hall-ui/public/harbor.css +123 -0
  165. package/modules/hall-ui/public/harbor.html +3 -120
  166. package/modules/hall-ui/public/index.html +11 -4
  167. package/modules/hall-ui/public/instances-panel.js +4 -1
  168. package/modules/hall-ui/public/modules.css +63 -0
  169. package/modules/hall-ui/public/modules.html +3 -60
  170. package/modules/hall-ui/public/my-projects.html +78 -0
  171. package/modules/hall-ui/public/my-projects.js +142 -0
  172. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  173. package/modules/hall-ui/public/not-ready.html +2 -0
  174. package/modules/hall-ui/public/pair.css +73 -0
  175. package/modules/hall-ui/public/pair.html +3 -70
  176. package/modules/hall-ui/public/palette.js +302 -0
  177. package/modules/hall-ui/public/primer.css +13 -0
  178. package/modules/hall-ui/public/primer.html +2 -0
  179. package/modules/hall-ui/public/profile.html +2 -0
  180. package/modules/hall-ui/public/ranks.html +2 -0
  181. package/modules/hall-ui/public/roadmap.css +179 -0
  182. package/modules/hall-ui/public/roadmap.html +9 -172
  183. package/modules/hall-ui/public/roadmap.js +329 -316
  184. package/modules/hall-ui/public/scouting.html +45 -0
  185. package/modules/hall-ui/public/scouting.js +73 -0
  186. package/modules/hall-ui/public/sessions.css +16 -0
  187. package/modules/hall-ui/public/sessions.html +3 -13
  188. package/modules/hall-ui/public/settings.html +2 -0
  189. package/modules/hall-ui/public/settings.js +4 -4
  190. package/modules/hall-ui/public/shell.js +143 -18
  191. package/modules/hall-ui/public/style.css +231 -39
  192. package/modules/hall-ui/public/task-detail.js +347 -26
  193. package/modules/hall-ui/public/task.html +6 -1
  194. package/modules/hall-ui/public/watch.html +2 -0
  195. package/modules/hall-ui/public/work.html +8 -6
  196. package/modules/hall-ui/public/work.js +1 -1
  197. package/modules/ideas/inbox.js +61 -11
  198. package/modules/ideas/routes/inbox.js +23 -0
  199. package/modules/lifecycle/claim-eligibility.js +72 -0
  200. package/modules/lifecycle/db.js +519 -139
  201. package/modules/lifecycle/done-when.js +18 -3
  202. package/modules/lifecycle/github-push.js +19 -2
  203. package/modules/lifecycle/goal-advisory.js +114 -0
  204. package/modules/lifecycle/goal-authz.js +62 -0
  205. package/modules/lifecycle/lifecycle.js +9 -0
  206. package/modules/lifecycle/publish-reconciler.js +109 -6
  207. package/modules/lifecycle/routes/claims.js +68 -23
  208. package/modules/lifecycle/routes/done-when.js +110 -15
  209. package/modules/lifecycle/routes/goals.js +8 -38
  210. package/modules/lifecycle/routes/tasks.js +38 -6
  211. package/modules/lifecycle/ship-card.js +6 -0
  212. package/modules/onboarding/onboarding-state.js +1 -1
  213. package/modules/platform-identity/CLAUDE.md +15 -0
  214. package/modules/platform-identity/hub-device.js +147 -0
  215. package/modules/platform-identity/hub-keys.js +151 -0
  216. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  217. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  218. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  219. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  220. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  221. package/modules/platform-identity/module.json +18 -0
  222. package/modules/platform-identity/platform-identity.js +531 -0
  223. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  224. package/modules/platform-identity/routes/my-projects.js +140 -0
  225. package/modules/platform-identity/routes/projects.js +107 -0
  226. package/modules/platform-identity/routes/scouting.js +35 -0
  227. package/modules/platform-identity/routes/sso.js +402 -0
  228. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  229. package/modules/platform-identity/ui/.gitkeep +0 -0
  230. package/modules/security/routes/reports.js +10 -0
  231. package/modules/sessions/db.js +166 -21
  232. package/modules/sessions/module.json +1 -1
  233. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  234. package/modules/sessions/routes/sessions.js +33 -0
  235. package/modules/status-ui/public/explore.html +54 -0
  236. package/modules/status-ui/public/explore.js +67 -0
  237. package/package-lock.json +2 -2
  238. package/package.json +1 -1
  239. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  270. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  271. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  272. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  273. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  274. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  275. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  276. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  277. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  278. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  279. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  280. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  281. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  282. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  283. package/public-landing/create.html +252 -62
  284. package/public-landing/gate/README.md +137 -0
  285. package/public-landing/gate/dispmaps/bar.png +0 -0
  286. package/public-landing/gate/dispmaps/flat.png +0 -0
  287. package/public-landing/gate/dispmaps/key.png +0 -0
  288. package/public-landing/gate/index.html +587 -0
  289. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  290. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  291. package/public-landing/gate/space.css +799 -0
  292. package/public-landing/gate/space.js +1826 -0
  293. package/public-landing/gate/sprites/enter.png +0 -0
  294. package/public-landing/gate/sprites/search.png +0 -0
  295. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  296. package/public-landing/gate/sprites/tile.png +0 -0
  297. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  298. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  299. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  300. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  301. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  302. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  303. package/scripts/gds/abandon.js +6 -4
  304. package/scripts/gds/api.js +10 -5
  305. package/scripts/gds/api.ps1 +26 -0
  306. package/scripts/gds/api.sh +6 -0
  307. package/scripts/gds/audit-ships.js +7 -1
  308. package/scripts/gds/backfill-sessions.js +78 -18
  309. package/scripts/gds/backup.js +23 -1
  310. package/scripts/gds/box-connect-lib.js +124 -31
  311. package/scripts/gds/box-sync.js +66 -0
  312. package/scripts/gds/box.js +225 -53
  313. package/scripts/gds/cf-tunnel.js +145 -6
  314. package/scripts/gds/claim.js +34 -3
  315. package/scripts/gds/cli-lib.js +161 -21
  316. package/scripts/gds/code.js +6 -2
  317. package/scripts/gds/deploy-divergence.js +209 -0
  318. package/scripts/gds/dev-lib.js +309 -0
  319. package/scripts/gds/dev.js +322 -0
  320. package/scripts/gds/doctor.js +90 -61
  321. package/scripts/gds/fetch-art-key.js +1 -1
  322. package/scripts/gds/fetch-sound-prefs.js +2 -2
  323. package/scripts/gds/fitness.js +228 -12
  324. package/scripts/gds/gds-db-backup.service +16 -1
  325. package/scripts/gds/gds-db-offsite.service +9 -1
  326. package/scripts/gds/gen-api-client.js +1 -1
  327. package/scripts/gds/gen-api-docs.js +36 -14
  328. package/scripts/gds/gen-diagrams.js +45 -28
  329. package/scripts/gds/gen-file-map.js +2 -1
  330. package/scripts/gds/gen-freshness.js +32 -0
  331. package/scripts/gds/gen-repo-map.js +3 -2
  332. package/scripts/gds/gen-session-index.js +14 -5
  333. package/scripts/gds/hooks/pre-push.js +2 -2
  334. package/scripts/gds/init.js +60 -3
  335. package/scripts/gds/install-git-hooks.js +1 -1
  336. package/scripts/gds/key-card.js +15 -6
  337. package/scripts/gds/key-status.js +1 -1
  338. package/scripts/gds/linkify-refs.js +35 -9
  339. package/scripts/gds/lint-migrations.js +102 -0
  340. package/scripts/gds/login.js +58 -11
  341. package/scripts/gds/mirror-redact.js +5 -0
  342. package/scripts/gds/package-core.js +263 -4
  343. package/scripts/gds/paste-token.js +7 -1
  344. package/scripts/gds/preflight.js +126 -0
  345. package/scripts/gds/provision.js +268 -19
  346. package/scripts/gds/publish-manifest.js +22 -1
  347. package/scripts/gds/reauth.js +7 -3
  348. package/scripts/gds/regen-instance-docs.js +47 -0
  349. package/scripts/gds/run-unit-tests.js +42 -16
  350. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  351. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  352. package/scripts/gds/sequence.js +425 -0
  353. package/scripts/gds/session-cost.js +10 -1
  354. package/scripts/gds/setup.js +72 -5
  355. package/scripts/gds/shell.js +5 -3
  356. package/scripts/gds/ship.js +433 -59
  357. package/scripts/gds/smoke-gds.js +139 -8
  358. package/scripts/gds/start.js +171 -22
  359. package/scripts/gds/status.js +1 -1
  360. package/scripts/gds/triage.js +10 -9
  361. package/scripts/gds/upgrade.js +271 -7
  362. package/scripts/migrate.sh +10 -0
  363. package/scripts/status-mirror-sync.js +114 -84
  364. package/src/bongos/api-errors.js +1 -0
  365. package/src/bongos/auth.js +809 -107
  366. package/src/bongos/db-kernel.js +27 -0
  367. package/src/bongos/db.js +105 -0
  368. package/src/bongos/github-repos.js +93 -0
  369. package/src/bongos/module-scope-map.js +27 -1
  370. package/src/bongos/permission-path-check.js +136 -102
  371. package/src/bongos/repo-info.js +47 -11
  372. package/src/bongos/route-rank-check.js +429 -46
  373. package/src/bongos/routes/_helpers.js +12 -1
  374. package/src/bongos/routes/auth.js +312 -13
  375. package/src/bongos/routes/backup.js +58 -2
  376. package/src/bongos/routes/github.js +90 -0
  377. package/src/bongos/routes/instance.js +15 -3
  378. package/src/bongos/routes/me.js +35 -1
  379. package/src/bongos/routes.js +2 -0
  380. package/src/bongos/serve-internal.js +84 -15
  381. package/src/branding.js +49 -2
  382. package/src/instance-config.js +46 -0
  383. package/src/module-api.js +140 -1
  384. package/src/platform-server.js +86 -9
  385. package/tests/abandon_task.mjs +59 -3
  386. package/tests/api_cli_flags.mjs +53 -0
  387. package/tests/auth_page_gate.mjs +13 -0
  388. package/tests/backfill_sessions.mjs +127 -1
  389. package/tests/backup_cli.mjs +54 -0
  390. package/tests/bongos_box_connect.mjs +77 -0
  391. package/tests/box_access.mjs +130 -1
  392. package/tests/box_credential.mjs +125 -0
  393. package/tests/box_host_keys.mjs +24 -0
  394. package/tests/box_infra_resolve.mjs +149 -0
  395. package/tests/box_scope_session.mjs +6 -2
  396. package/tests/box_sync.mjs +55 -0
  397. package/tests/box_task_scope.mjs +99 -0
  398. package/tests/branding.mjs +41 -23
  399. package/tests/cf_ruleset_preflight.mjs +142 -0
  400. package/tests/cf_tunnel.mjs +37 -0
  401. package/tests/claim_gate_rebase.mjs +98 -0
  402. package/tests/clawback_confirmed_backlog.mjs +103 -0
  403. package/tests/cli_lib_formatters.mjs +20 -0
  404. package/tests/confirm_attribution.mjs +30 -46
  405. package/tests/credit_grant.mjs +198 -10
  406. package/tests/criterion_authoring_gate.mjs +272 -0
  407. package/tests/criterion_progress.mjs +33 -1
  408. package/tests/dev_lib.mjs +253 -0
  409. package/tests/devbox_artifact_gate.mjs +75 -0
  410. package/tests/devbox_dmg_backdrop.mjs +137 -0
  411. package/tests/devbox_icons.mjs +304 -0
  412. package/tests/devbox_installer_brand.mjs +232 -0
  413. package/tests/devbox_key_register.mjs +234 -0
  414. package/tests/docs_openapi_route.mjs +110 -0
  415. package/tests/doctor_hooks.mjs +141 -0
  416. package/tests/extract_json.mjs +140 -0
  417. package/tests/fitness.mjs +89 -0
  418. package/tests/gen_freshness.mjs +96 -0
  419. package/tests/github_repos_helpers.mjs +91 -0
  420. package/tests/github_token_store.mjs +121 -0
  421. package/tests/goal_advisory.mjs +251 -0
  422. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  423. package/tests/goal_routes.mjs +59 -4
  424. package/tests/goal_tier.mjs +12 -3
  425. package/tests/governance_catalog.mjs +129 -0
  426. package/tests/governance_claim_eligibility.mjs +262 -0
  427. package/tests/governance_config.mjs +187 -0
  428. package/tests/governance_ownership_scope.mjs +410 -0
  429. package/tests/governance_page_gates.mjs +324 -0
  430. package/tests/governance_parity.mjs +336 -0
  431. package/tests/governance_principals.mjs +232 -0
  432. package/tests/governance_protected_surfaces.mjs +208 -0
  433. package/tests/governance_require_permission.mjs +223 -0
  434. package/tests/governance_resolver.mjs +84 -0
  435. package/tests/governance_role_sync.mjs +231 -0
  436. package/tests/governance_seed.mjs +127 -0
  437. package/tests/governance_session_scope.mjs +434 -0
  438. package/tests/governance_vanilla_boot.mjs +203 -0
  439. package/tests/grade_attribution.mjs +7 -7
  440. package/tests/hall_audit.mjs +223 -0
  441. package/tests/hall_nav.mjs +191 -0
  442. package/tests/hall_palette.mjs +206 -0
  443. package/tests/hall_signout.mjs +124 -0
  444. package/tests/hook_user_agents.mjs +10 -0
  445. package/tests/idea_triage.mjs +162 -0
  446. package/tests/idp_device_exchange.mjs +198 -0
  447. package/tests/idp_federation.mjs +100 -0
  448. package/tests/init.mjs +51 -0
  449. package/tests/instance_config.mjs +65 -0
  450. package/tests/instance_mismatch_guard.mjs +23 -6
  451. package/tests/instance_release_config.mjs +104 -0
  452. package/tests/linkify_refs.mjs +15 -2
  453. package/tests/migrations_forward_safe.mjs +86 -0
  454. package/tests/module_api.mjs +1 -1
  455. package/tests/module_contributions.mjs +55 -0
  456. package/tests/module_loader.mjs +10 -5
  457. package/tests/module_scope_active_claims.mjs +94 -0
  458. package/tests/needs_rebase.mjs +92 -0
  459. package/tests/one_active_claim_per_session.mjs +76 -0
  460. package/tests/override_attribution.mjs +10 -8
  461. package/tests/override_request_ownership.mjs +8 -4
  462. package/tests/package_core.mjs +246 -1
  463. package/tests/parse_cookie_decode.mjs +42 -0
  464. package/tests/pay_on_land.mjs +129 -0
  465. package/tests/platform_boot.mjs +100 -1
  466. package/tests/post_login_landing.mjs +42 -0
  467. package/tests/preflight_checks.mjs +72 -0
  468. package/tests/provision.mjs +289 -7
  469. package/tests/pruned_lockfile_integrity.mjs +79 -0
  470. package/tests/publish_branch_route.mjs +53 -0
  471. package/tests/publish_reconciler.mjs +187 -2
  472. package/tests/repo_info_binding.mjs +248 -0
  473. package/tests/reward_gate.mjs +11 -2
  474. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  475. package/tests/reward_mode_surfacing.mjs +168 -0
  476. package/tests/route_permission_check.mjs +301 -0
  477. package/tests/route_rank_check.mjs +254 -12
  478. package/tests/route_validation.mjs +30 -0
  479. package/tests/sequence.mjs +267 -0
  480. package/tests/session_digest.mjs +44 -0
  481. package/tests/session_index.mjs +18 -6
  482. package/tests/session_records.mjs +39 -1
  483. package/tests/session_reward_on_land.mjs +381 -0
  484. package/tests/session_reward_reconcile.mjs +229 -0
  485. package/tests/ship_api_client_pathspec.mjs +100 -0
  486. package/tests/ship_base_flag.mjs +93 -0
  487. package/tests/ship_ci_deploy.mjs +140 -3
  488. package/tests/ship_upstream_deletion.mjs +57 -0
  489. package/tests/smoke_target.mjs +233 -0
  490. package/tests/start_env_guard.mjs +87 -0
  491. package/tests/start_rebase_warning.mjs +103 -0
  492. package/tests/status_mirror_sync.mjs +122 -0
  493. package/tests/stop_refcheck.mjs +19 -1
  494. package/tests/task_goal_link.mjs +15 -6
  495. package/tests/upgrade.mjs +393 -5
  496. package/tests/web_admission_status.mjs +162 -0
  497. package/tests/write_validation_gaps.mjs +110 -0
  498. package/.gitignore +0 -91
  499. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  500. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  501. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  502. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  503. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  504. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  505. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  506. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  507. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  508. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  509. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  510. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  511. package/scripts/hall-preview/fixtures/goals.json +0 -502
  512. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  513. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  514. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  515. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  516. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  517. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  518. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  519. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  520. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  521. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  522. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  523. package/scripts/hall-preview/fixtures/instance.json +0 -124
  524. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  525. package/scripts/hall-preview/fixtures/me.json +0 -476
  526. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  527. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  528. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  529. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  530. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  531. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  532. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  533. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  534. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  535. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  536. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  537. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  538. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  539. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  540. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  541. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  542. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  543. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  544. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  545. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  546. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  547. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  548. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  549. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  550. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  551. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  552. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  553. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -41,6 +41,7 @@ const {
41
41
  getBuilderById,
42
42
  getBuilderByGithubId,
43
43
  hasInvitedAccessRequest,
44
+ accessStateByLogin,
44
45
  RANKS,
45
46
  } = require('./db-kernel');
46
47
  const { pool } = require('./pool');
@@ -55,6 +56,20 @@ const { branding, userAgent } = require('../branding');
55
56
 
56
57
  const SCOPE = 'read:user';
57
58
 
59
+ // Web-flow OAuth scope. Identity (`read:user`) is all the builders'-hall sign-in
60
+ // 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.
69
+ function scopeForWebFlow({ repos = false } = {}) {
70
+ return repos ? `${SCOPE} public_repo` : SCOPE;
71
+ }
72
+
58
73
  // In-memory map of device_code → { interval, expires_at, last_polled_at, github_creds }
59
74
  // Device flow is ephemeral; restart of the Node process invalidates pending flows
60
75
  // (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
@@ -91,6 +106,116 @@ function authConfigured() {
91
106
  return !!loadGithubCreds();
92
107
  }
93
108
 
109
+ // -------------------------------------------------------------------------
110
+ // Platform IdP federation (ADR 0141 §1) — when enabled, the instance's web +
111
+ // CLI sign-in federate through the Cloud Bongos hub instead of talking to GitHub
112
+ // directly. Identity is federated; authority is NOT — the instance still runs its
113
+ // OWN admission gate + upsertBuilder + createSession, so the hub only answers
114
+ // "who is this GitHub user" (ADR 0016). Direct-GitHub (auth.idp unset) stays the
115
+ // unchanged fallback for self-hosted instances.
116
+ // -------------------------------------------------------------------------
117
+
118
+ // The switch is the branding auth.idp flag (committed, non-secret). Fail-soft to
119
+ // false so a branding hiccup degrades to the direct-GitHub path rather than
120
+ // breaking sign-in.
121
+ function idpEnabled() {
122
+ try { return branding().auth?.idp === true; } catch { return false; }
123
+ }
124
+
125
+ // Resolve the hub federation config. The switch is the flag above; the hub origin
126
+ // + client credentials come from ENV (<PREFIX>_HUB_ORIGIN / _HUB_CLIENT_ID /
127
+ // _HUB_CLIENT_SECRET) — never a committed file, so the secret stays out of the
128
+ // branding pack (mirrors loadGithubCreds's env-first posture). Provisioning
129
+ // (ADR 0141 §4) writes these into the instance web.env. Returns null unless the
130
+ // flag is on AND all three resolve → callers fall back to direct GitHub.
131
+ function loadIdpConfig() {
132
+ if (!idpEnabled()) return null;
133
+ const origin = ic.resolveEnv('HUB_ORIGIN');
134
+ const clientId = ic.resolveEnv('HUB_CLIENT_ID');
135
+ const clientSecret = ic.resolveEnv('HUB_CLIENT_SECRET');
136
+ if (origin && clientId && clientSecret) {
137
+ return { origin: String(origin).replace(/\/+$/, ''), clientId, clientSecret };
138
+ }
139
+ return null;
140
+ }
141
+
142
+ function idpConfigured() {
143
+ return !!loadIdpConfig();
144
+ }
145
+
146
+ // The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
147
+ // builder is the instance's configured firstAdmin, else builder. NEVER a rank.
148
+ function membershipRole({ githubLogin } = {}) {
149
+ const fa = firstAdminLogin();
150
+ if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
151
+ return 'builder';
152
+ }
153
+
154
+ // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
155
+ // github_id builds on project <client_id>, coarse role <role>". Runs on every
156
+ // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
157
+ // project still appears in the builder's My Projects with no hub browser session.
158
+ // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
159
+ // the onboarding stage tick). No-op on a self-hosted instance (idp unset).
160
+ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
161
+ const idp = loadIdpConfig();
162
+ if (!idp) return;
163
+ try {
164
+ await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
167
+ body: JSON.stringify({
168
+ client_id: idp.clientId,
169
+ client_secret: idp.clientSecret,
170
+ github_id: githubId,
171
+ github_login: githubLogin,
172
+ display_name: displayName ?? null,
173
+ avatar_url: avatarUrl ?? null,
174
+ role: membershipRole({ githubLogin }),
175
+ }),
176
+ });
177
+ } catch (err) {
178
+ console.log('[auth] hub membership check-in failed (non-blocking):', err.message);
179
+ }
180
+ }
181
+
182
+ // Best-effort cross-project activity rollup to the hub (ADR 0141 §4): report this
183
+ // builder's descriptive talent snapshot (credits, ships, karma, rank, disciplines)
184
+ // for the internal scouting directory. Fires on every federated sign-in ("on key
185
+ // events"); true periodic reporting is a follow-up. Fully swallowed + no-op when
186
+ // not federated — never fails a sign-in.
187
+ async function reportActivityRollup({ builderId, githubId }) {
188
+ const idp = loadIdpConfig();
189
+ if (!idp) return;
190
+ try {
191
+ // Snapshot the builder's talent signals directly from the DB (auth.js is a
192
+ // kernel file — it uses pool, never a non-kernel domain module like metrics.js).
193
+ const b = await pool.query(
194
+ 'SELECT total_credits, karma, rank, preferred_disciplines FROM builders WHERE id = $1',
195
+ [builderId],
196
+ );
197
+ if (!b.rows[0]) return;
198
+ const shipped = await pool.query('SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1', [builderId]);
199
+ const row = b.rows[0];
200
+ await fetch(`${idp.origin}/api/gds/sso/activity/rollup`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
203
+ body: JSON.stringify({
204
+ client_id: idp.clientId,
205
+ client_secret: idp.clientSecret,
206
+ github_id: githubId,
207
+ credits: Number(row.total_credits) || 0,
208
+ tasks_shipped: shipped.rows[0] ? shipped.rows[0].n : 0,
209
+ karma: Number(row.karma) || 0,
210
+ rank: row.rank || null,
211
+ disciplines: Array.isArray(row.preferred_disciplines) ? row.preferred_disciplines : [],
212
+ }),
213
+ });
214
+ } catch (err) {
215
+ console.log('[auth] hub activity rollup failed (non-blocking):', err.message);
216
+ }
217
+ }
218
+
94
219
  // Admission posture (SR-19 / task #1000). BOTH enrollment paths — the web hall
95
220
  // sign-in (completeWebFlow) and the CLI device flow (pollDeviceFlow) — are
96
221
  // "invite-gated by default": a FIRST-TIME GitHub account is admitted only if an
@@ -126,6 +251,48 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
126
251
  return true;
127
252
  }
128
253
 
254
+ // webAdmissionStatus — "would this login get in RIGHT NOW, and is it worth
255
+ // waiting?" (task 1002097). Backs the waiting page that replaced the red
256
+ // "not approved" dead-end a firstAdmin hits during the bootstrap window.
257
+ //
258
+ // `admitted` deliberately mirrors firstTimeAdmissionBlocked's own terms —
259
+ // member OR open-enrollment OR invited OR configured-firstAdmin — so the probe
260
+ // can never say "come in" while the real gate says otherwise. It reads state;
261
+ // it decides nothing. firstTimeAdmissionBlocked remains the one gate both flows
262
+ // call (SR-19); if that logic changes, this must change with it.
263
+ // tests/web_admission_status.mjs sweeps the input space asserting the two agree.
264
+ //
265
+ // `expectedSoon` is the broader "keep polling" signal: admitted, or waiting on
266
+ // an Archon (pending), or the configured firstAdmin whose instance is still
267
+ // booting. A visitor who is none of these is a genuine stranger and gets the
268
+ // existing request-access page rather than a spinner that never resolves.
269
+ //
270
+ // NB the onboarding module's public GET /access-requests/status (used by the
271
+ // CLI) reports member|invited|pending|none but knows nothing about the
272
+ // firstAdmin bypass or open enrollment — so it reports the OWNER as 'none'
273
+ // forever. That is exactly why this auth-domain probe exists.
274
+ async function webAdmissionStatus(login) {
275
+ const name = String(login || '').trim();
276
+ if (!name) return { admitted: false, expectedSoon: false };
277
+
278
+ const fa = firstAdminLogin();
279
+ const isFirstAdmin = !!fa && name.toLowerCase() === fa.toLowerCase();
280
+
281
+ let state = { member: false, invited: false, pending: false };
282
+ try {
283
+ state = await accessStateByLogin(name);
284
+ } catch (err) {
285
+ // A DB hiccup must not turn the waiting page into a false "you're in".
286
+ // Fail closed on `admitted`; still let a firstAdmin keep polling, since
287
+ // that is the bootstrap window this page exists for.
288
+ console.error('[auth] webAdmissionStatus', err);
289
+ return { admitted: false, expectedSoon: isFirstAdmin };
290
+ }
291
+
292
+ const admitted = state.member || openEnrollmentEnabled() || state.invited || isFirstAdmin;
293
+ return { admitted, expectedSoon: admitted || state.pending || isFirstAdmin };
294
+ }
295
+
129
296
  // First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
130
297
  // login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
131
298
  // else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
@@ -160,6 +327,34 @@ async function maybeSeatFirstAdmin(builder) {
160
327
  return builder;
161
328
  }
162
329
 
330
+ // safeSyncRankRole — give a builder the seeded rank-role matching their rank at
331
+ // REGISTRATION (BV1.R95b, ADR 0151). governance_002's builder→role backfill is
332
+ // run-once, so before this every builder admitted after that migration had no role
333
+ // row at all and resolved to the EMPTY permission set — denied on every gated route.
334
+ //
335
+ // Called at each sign-in rather than only on first insert, and deliberately so: the
336
+ // call is idempotent (two SELECTs and no write once the role already matches), it
337
+ // also covers the rank write maybeSeatFirstAdmin just made (the firstAdmin bootstrap
338
+ // flips xenos→archon outside any transaction), and it quietly repairs a builder whose
339
+ // assignment drifted for any other reason.
340
+ //
341
+ // Resolved through the `governance.roleSync` kernel port — auth.js is a KERNEL_FILE
342
+ // and may not require a module (ADR 0083 / ADR 0091 §1). Runs on the pool: there is
343
+ // no surrounding transaction here, unlike the two rank-change paths, which pass their
344
+ // own client so the rotation is atomic with the rank write.
345
+ //
346
+ // BEST-EFFORT: a governance hiccup must never fail a sign-in, and the failure mode is
347
+ // fail-closed anyway — no role means zero permissions, never extra ones.
348
+ async function safeSyncRankRole(builder) {
349
+ if (!builder || builder.id == null) return;
350
+ try {
351
+ await seams.resolveOptional('governance.roleSync')
352
+ ?.syncRankRole(null, { builderId: builder.id, rank: builder.rank });
353
+ } catch (err) {
354
+ console.error('[auth] rank-role sync failed (non-fatal):', err.message);
355
+ }
356
+ }
357
+
163
358
  function newSessionToken() {
164
359
  return crypto.randomBytes(32).toString('hex');
165
360
  }
@@ -201,7 +396,11 @@ async function exchangeCodeForToken(code, redirectUri) {
201
396
  });
202
397
  const data = await res.json();
203
398
  if (data.error) throw new Error(`GitHub token exchange: ${data.error_description || data.error}`);
204
- return data.access_token;
399
+ // Return the GRANTED scope alongside the token. GitHub echoes back the scopes
400
+ // the user actually consented to (space/comma-separated) — the caller checks it
401
+ // before persisting a repo-capable token, so a user who declined the broader
402
+ // consent never has one stored (task 1002099 / ADR 0143).
403
+ return { accessToken: data.access_token, scope: data.scope || '' };
205
404
  }
206
405
 
207
406
  // -------------------------------------------------------------------------
@@ -339,6 +538,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
339
538
  avatarUrl: ghUser.avatar_url,
340
539
  });
341
540
  builder = await maybeSeatFirstAdmin(builder);
541
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
342
542
  const token = newSessionToken();
343
543
  await createSession({
344
544
  builderId: builder.id,
@@ -360,19 +560,21 @@ async function pollDeviceFlow(deviceCode, userAgent) {
360
560
  // Web Flow (browser)
361
561
  // -------------------------------------------------------------------------
362
562
 
363
- function buildAuthorizeUrl(state, redirectUri) {
563
+ function buildAuthorizeUrl(state, redirectUri, { repos = false } = {}) {
364
564
  const creds = loadGithubCreds();
365
565
  if (!creds) throw new Error('GitHub OAuth credentials not configured');
366
566
  const u = new URL('https://github.com/login/oauth/authorize');
367
567
  u.searchParams.set('client_id', creds.client_id);
368
568
  u.searchParams.set('redirect_uri', redirectUri);
369
- u.searchParams.set('scope', SCOPE);
569
+ // `repos:true` (the onboarding wizard only) requests the broader repo scope; every
570
+ // other caller gets identity-only. scopeForWebFlow keeps that decision in one place.
571
+ u.searchParams.set('scope', scopeForWebFlow({ repos }));
370
572
  u.searchParams.set('state', state);
371
573
  return u.toString();
372
574
  }
373
575
 
374
- async function completeWebFlow({ code, redirectUri, userAgent }) {
375
- const accessToken = await exchangeCodeForToken(code, redirectUri);
576
+ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken = false }) {
577
+ const { accessToken, scope: grantedScope } = await exchangeCodeForToken(code, redirectUri);
376
578
  const ghUser = await fetchGithubUser(accessToken);
377
579
  // Approval gate (2026-05-30): the web hall sign-in no longer admits anyone
378
580
  // with a GitHub account. A first-time visitor must have been approved by an
@@ -400,6 +602,13 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
400
602
  if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
401
603
  const err = new Error('not_approved');
402
604
  err.code = 'NOT_APPROVED';
605
+ // Carry the login so the route can tell an imminent admission (the
606
+ // configured firstAdmin mid-bootstrap, or someone with a pending request)
607
+ // from a genuine stranger, and show the "confirming access" waiting page
608
+ // instead of a red dead-end (task 1002097). Attaching it here — rather
609
+ // than re-deriving it in the route — keeps the login the gate actually
610
+ // judged. It is NOT a decision: the route re-checks via webAdmissionStatus.
611
+ err.login = ghUser.login;
403
612
  throw err;
404
613
  }
405
614
  }
@@ -410,6 +619,7 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
410
619
  avatarUrl: ghUser.avatar_url,
411
620
  });
412
621
  builder = await maybeSeatFirstAdmin(builder);
622
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
413
623
  const token = newSessionToken();
414
624
  // Web sessions expire after 30 days; the user re-auths on the page
415
625
  // when the cookie expires.
@@ -423,9 +633,285 @@ async function completeWebFlow({ code, redirectUri, userAgent }) {
423
633
  });
424
634
  // V3.R75 (#294): tick the onboarding state machine. Best-effort.
425
635
  await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
636
+ // Wizard opt-in only (task 1002099 / ADR 0143): when the caller asked to capture
637
+ // repo access, hand the GitHub access token + granted scope back so the web
638
+ // callback can persist it (encrypted, short-TTL) for the repo picker/creator.
639
+ // auth.js stays kernel-clean — it never writes the token to the DB itself; the
640
+ // token is surfaced ONLY on this opt-in path, so an ordinary hall sign-in never
641
+ // even returns it.
642
+ const captured = captureRepoToken ? { githubToken: accessToken, githubScope: grantedScope } : {};
643
+ return { token, builder, expiresAt, ...captured };
644
+ }
645
+
646
+ // -------------------------------------------------------------------------
647
+ // Platform IdP web flow (ADR 0141 §1) — the instance as an OAuth client of the hub
648
+ // -------------------------------------------------------------------------
649
+
650
+ // Build the hub's authorize URL. The instance redirects the browser here (instead
651
+ // of github.com) at /auth/web/start when idpConfigured(). redirectUri is the
652
+ // instance's OWN /auth/web/callback (registered in the hub's sso_clients allow-list
653
+ // by provisioning); state is the same CSRF nonce the callback validates.
654
+ // PURE — construct the hub authorize URL (testable without branding/env). Trailing
655
+ // slashes on origin are trimmed so `https://hub/` and `https://hub` behave alike.
656
+ function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
657
+ const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/gds/sso/authorize`);
658
+ u.searchParams.set('client_id', clientId);
659
+ u.searchParams.set('redirect_uri', redirectUri);
660
+ u.searchParams.set('state', state);
661
+ return u.toString();
662
+ }
663
+
664
+ function buildIdpAuthorizeUrl(state, redirectUri) {
665
+ const idp = loadIdpConfig();
666
+ if (!idp) throw new Error('platform IdP not configured');
667
+ return idpAuthorizeUrl(idp.origin, idp.clientId, state, redirectUri);
668
+ }
669
+
670
+ // Server-to-server: redeem the hub authorization code for identity. Authenticates
671
+ // with the instance's client_id + client_secret; the hub returns IDENTITY ONLY
672
+ // (github_id, github_login, display_name, avatar_url) — never a rank (ADR 0141 §2).
673
+ async function exchangeIdpCode({ code, redirectUri, idp }) {
674
+ const res = await fetch(`${idp.origin}/api/gds/sso/token`, {
675
+ method: 'POST',
676
+ headers: {
677
+ 'Content-Type': 'application/json',
678
+ Accept: 'application/json',
679
+ 'User-Agent': userAgent(),
680
+ },
681
+ body: JSON.stringify({
682
+ client_id: idp.clientId,
683
+ client_secret: idp.clientSecret,
684
+ code,
685
+ redirect_uri: redirectUri,
686
+ }),
687
+ });
688
+ if (!res.ok) {
689
+ const text = await res.text().catch(() => '');
690
+ throw new Error(`hub /sso/token failed (${res.status}): ${text.slice(0, 200)}`);
691
+ }
692
+ const data = await res.json();
693
+ if (!data || data.github_id == null) {
694
+ throw new Error('hub /sso/token returned no identity');
695
+ }
696
+ return data;
697
+ }
698
+
699
+ // Complete the instance web sign-in via the hub. Sources identity from the hub's
700
+ // /sso/token, then runs the EXACT SAME admission gate + upsertBuilder +
701
+ // maybeSeatFirstAdmin + createSession(source='web') as completeWebFlow — only the
702
+ // identity SOURCE differs (the hub, not github.com). The shared PURE gate
703
+ // (firstTimeAdmissionBlocked) is what keeps this path from drifting from the direct
704
+ // path (SR-19). Returns the same { token, builder, expiresAt } shape.
705
+ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
706
+ const idp = loadIdpConfig();
707
+ if (!idp) throw new Error('platform IdP not configured');
708
+ const identity = await exchangeIdpCode({ code, redirectUri, idp });
709
+ const ghId = identity.github_id;
710
+ const ghLogin = identity.github_login;
711
+ const existing = await getBuilderByGithubId(ghId);
712
+ if (!existing && !openEnrollmentEnabled()) {
713
+ const approved = await hasInvitedAccessRequest(ghLogin);
714
+ if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
715
+ const err = new Error('not_approved');
716
+ err.code = 'NOT_APPROVED';
717
+ throw err;
718
+ }
719
+ }
720
+ let builder = await upsertBuilder({
721
+ githubId: ghId,
722
+ githubLogin: ghLogin,
723
+ displayName: identity.display_name || ghLogin,
724
+ avatarUrl: identity.avatar_url,
725
+ });
726
+ builder = await maybeSeatFirstAdmin(builder);
727
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
728
+ const token = newSessionToken();
729
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
730
+ await createSession({
731
+ builderId: builder.id,
732
+ token,
733
+ source: 'web',
734
+ userAgent: ua ?? null,
735
+ expiresAt,
736
+ });
737
+ await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
738
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated web sign-in.
739
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: identity.display_name, avatarUrl: identity.avatar_url });
740
+ reportActivityRollup({ builderId: builder.id, githubId: ghId });
426
741
  return { token, builder, expiresAt };
427
742
  }
428
743
 
744
+ // -------------------------------------------------------------------------
745
+ // Platform IdP CLI/device flow (ADR 0141 §1) — the instance verifies a hub-signed
746
+ // assertion OFFLINE and mints its own cli session. The CLI runs the device flow
747
+ // against the HUB (GitHub device codes can't cross apps), the hub signs an
748
+ // audience-scoped Ed25519 assertion, and the instance verifies it here with the
749
+ // hub's PUBLIC key (provisioned into env) — no instance->hub call at exchange time.
750
+ // -------------------------------------------------------------------------
751
+
752
+ const HUB_ASSERTION_ISSUER = 'cloudbongos-hub';
753
+
754
+ // The hub's Ed25519 PUBLIC key (base64 of the SPKI DER), provisioned into the
755
+ // instance env (<PREFIX>_HUB_PUBLIC_KEY). Returns a KeyObject or null.
756
+ function loadHubPublicKey() {
757
+ const b64 = ic.resolveEnv('HUB_PUBLIC_KEY');
758
+ if (!b64) return null;
759
+ try {
760
+ return crypto.createPublicKey({ key: Buffer.from(String(b64), 'base64'), format: 'der', type: 'spki' });
761
+ } catch (err) {
762
+ console.error('[auth] HUB_PUBLIC_KEY is set but unreadable:', err.message);
763
+ return null;
764
+ }
765
+ }
766
+
767
+ // Verify a hub-signed assertion (the compact-JWT format minted by the hub's
768
+ // platform-identity/hub-keys.js — kept in lockstep across the core<->module
769
+ // boundary, ADR 0091 §2). Verifies the Ed25519 signature FIRST (against the given
770
+ // public key), then the issuer, audience (must equal THIS instance's client_id),
771
+ // and expiry. The verify algorithm is HARDCODED to Ed25519 (`crypto.verify(null,…)`)
772
+ // regardless of the header's `alg` field, so there is no algorithm-confusion attack.
773
+ // Returns the validated payload, or throws. Pure over its inputs (testable).
774
+ function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
775
+ if (!publicKey) throw new Error('hub public key not configured');
776
+ const parts = String(token || '').split('.');
777
+ if (parts.length !== 3) throw new Error('malformed assertion');
778
+ const [h, p, s] = parts;
779
+ let header;
780
+ try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
781
+ catch { throw new Error('malformed assertion header'); }
782
+ if (!header || header.alg !== 'EdDSA') throw new Error('unexpected assertion alg');
783
+ // Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
784
+ const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
785
+ if (!ok) throw new Error('assertion signature invalid');
786
+ let payload;
787
+ try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
788
+ catch { throw new Error('malformed assertion payload'); }
789
+ if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected assertion issuer');
790
+ if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('assertion audience mismatch');
791
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('assertion expired');
792
+ if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
793
+ return payload;
794
+ }
795
+
796
+ // The purpose marker on a hub back-channel logout token — kept in lockstep with
797
+ // the hub minter (platform-identity/hub-keys.js LOGOUT_PURPOSE) across the
798
+ // boundary (ADR 0091 §2). Task 1002238.
799
+ const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
800
+
801
+ // Verify a hub-signed BACK-CHANNEL LOGOUT token (ADR 0144, task 1002238) — the
802
+ // logout sibling of verifyHubAssertion, same compact-EdDSA-JWT envelope, kept in
803
+ // lockstep with the hub minter (hub-keys.js mintHubLogoutToken). Verifies the
804
+ // Ed25519 signature FIRST (alg HARDCODED to Ed25519 regardless of the header, so
805
+ // no algorithm-confusion), then issuer, audience (must equal THIS instance's
806
+ // client_id, so a token minted for another project can't be replayed here),
807
+ // expiry, and that purpose === 'backchannel-logout'. Requiring `purpose` is what
808
+ // makes login assertions and logout tokens mutually non-interchangeable: a login
809
+ // assertion has no purpose (rejected here), and a logout token has no
810
+ // github_login (rejected by verifyHubAssertion). Returns the validated payload
811
+ // ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
812
+ function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
813
+ if (!publicKey) throw new Error('hub public key not configured');
814
+ const parts = String(token || '').split('.');
815
+ if (parts.length !== 3) throw new Error('malformed logout token');
816
+ const [h, p, s] = parts;
817
+ let header;
818
+ try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
819
+ catch { throw new Error('malformed logout token header'); }
820
+ if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
821
+ // Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
822
+ const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
823
+ if (!ok) throw new Error('logout token signature invalid');
824
+ let payload;
825
+ try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
826
+ catch { throw new Error('malformed logout token payload'); }
827
+ if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
828
+ if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
829
+ if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
830
+ if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
831
+ if (payload.sub == null) throw new Error('logout token missing subject');
832
+ return payload;
833
+ }
834
+
835
+ // Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
836
+ // aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
837
+ // assertion's own expiry, so a captured assertion can't be replayed into multiple
838
+ // sessions even inside its ~120s life. A restart clears it — acceptable given the
839
+ // short window and that the aud binding is the primary control.
840
+ const _seenAssertionJti = new Map(); // jti -> expiry ms
841
+ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
842
+ for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
843
+ if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
844
+ if (_seenAssertionJti.has(jti)) return true;
845
+ _seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
846
+ return false;
847
+ }
848
+
849
+ // Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
850
+ // assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
851
+ // a replay within the ~120s TTL is a no-op. Without this, a captured token stays a
852
+ // repeatable "log this builder out" primitive for its whole life — it would keep
853
+ // killing sessions the builder legitimately creates AFTER the token was minted
854
+ // (the one non-idempotent replay effect; revoking already-dead sessions is
855
+ // otherwise harmless). Separate map from assertions so the two token types never
856
+ // share dedupe state. In-memory per process; a restart clears it (acceptable — the
857
+ // window is short and the aud binding is the primary control). Exported for the
858
+ // route (routes/auth.js) since the DB revoke lives there, not in the kernel.
859
+ const _seenLogoutJti = new Map(); // jti -> expiry ms
860
+ function logoutTokenJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
861
+ for (const [k, exp] of _seenLogoutJti) if (exp < nowMs) _seenLogoutJti.delete(k);
862
+ if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
863
+ if (_seenLogoutJti.has(jti)) return true;
864
+ _seenLogoutJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
865
+ return false;
866
+ }
867
+
868
+ // Complete a CLI federation exchange: verify the hub assertion, then run the SAME
869
+ // admission gate + upsertBuilder + createSession(source='cli') as the direct device
870
+ // flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
871
+ async function completeDeviceExchange({ assertion, userAgent: ua }) {
872
+ const idp = loadIdpConfig();
873
+ if (!idp) throw new Error('platform IdP not configured');
874
+ const publicKey = loadHubPublicKey();
875
+ if (!publicKey) throw new Error('hub public key not configured');
876
+ const payload = verifyHubAssertion(assertion, { publicKey, clientId: idp.clientId });
877
+ // Reject a replayed assertion (same jti) within its window.
878
+ if (_assertionJtiSeenAndRecord(payload.jti, payload.exp)) {
879
+ throw new Error('assertion already used');
880
+ }
881
+ const ghId = payload.sub;
882
+ const ghLogin = payload.github_login;
883
+ const existing = await getBuilderByGithubId(ghId);
884
+ if (!existing && !openEnrollmentEnabled()) {
885
+ const approved = await hasInvitedAccessRequest(ghLogin);
886
+ if (firstTimeAdmissionBlocked({ existing, approved, login: ghLogin })) {
887
+ const err = new Error('not_approved');
888
+ err.code = 'NOT_APPROVED';
889
+ throw err;
890
+ }
891
+ }
892
+ let builder = await upsertBuilder({
893
+ githubId: ghId,
894
+ githubLogin: ghLogin,
895
+ displayName: payload.display_name || ghLogin,
896
+ avatarUrl: payload.avatar_url,
897
+ });
898
+ builder = await maybeSeatFirstAdmin(builder);
899
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
900
+ const token = newSessionToken();
901
+ await createSession({
902
+ builderId: builder.id,
903
+ token,
904
+ source: 'cli',
905
+ userAgent: ua ?? null,
906
+ expiresAt: null,
907
+ });
908
+ await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
909
+ // Fire-and-forget hub membership check-in + activity rollup (ADR 0141 §3/§4) — federated CLI sign-in.
910
+ reportMembershipCheckIn({ githubId: ghId, githubLogin: ghLogin, displayName: payload.display_name, avatarUrl: payload.avatar_url });
911
+ reportActivityRollup({ builderId: builder.id, githubId: ghId });
912
+ return { token, builder };
913
+ }
914
+
429
915
  // -------------------------------------------------------------------------
430
916
  // Discord OAuth (account-linking — Phase 3 F2, ADR 0033 §2)
431
917
  // -------------------------------------------------------------------------
@@ -648,17 +1134,133 @@ async function resolveSession(req) {
648
1134
  return null;
649
1135
  }
650
1136
 
651
- // SECURITY (#919): opt-in marker for the handful of dev-box bootstrap routes a
652
- // box-scoped session (source='box') is permitted to reach. Mount it BEFORE
653
- // requireBuilder on those routes; it only flags the request, and requireBuilder's
654
- // deny-by-default box-scope gate reads the flag. A route WITHOUT this middleware
655
- // rejects a box session, so this allow-list IS the complete box surface and any
656
- // new route is denied to boxes automatically (fail closed). No-op for cli/web
657
- // sessions — requireBuilder only consults the flag when source === 'box'.
658
- function allowBoxScope(req, _res, next) {
659
- req.allowBoxScope = true;
660
- next();
1137
+ // ── SESSION-SCOPES — capability attached to the TOKEN (BV1.R99, ADR 0151 §3) ──
1138
+ //
1139
+ // A session's SCOPE is orthogonal to its builder's roles: a dev-box session
1140
+ // (source='box') is baked onto a machine OUTSIDE the ADR 0016 trust boundary
1141
+ // (#919/SR#2, ADR 0053), so it must stay limited even when its builder is an
1142
+ // Archon. Before R99 that was an ad-hoc boolean (`req.allowBoxScope`) plus a
1143
+ // hardcoded `source === 'box'` test; it is now an explicit, declared model.
1144
+ //
1145
+ // A SESSION-SCOPE CAN ONLY EVER NARROW. Effective authority is (what the builder's
1146
+ // roles confer) ∩ (what this session's scope allows), with the allow-list
1147
+ // DENY-BY-DEFAULT. The intersection itself lives in the governance resolver
1148
+ // (resolveSessionPermissions — the composed answer the tab and permission gates
1149
+ // read); THIS file carries its route-reachability projection: "is this route
1150
+ // inside the session's scope at all". That projection is deliberately the ROLE-FREE
1151
+ // half — folding the builder's roles in here would change who can reach the
1152
+ // bootstrap routes (a builder on a rank with no seeded governance role resolves to
1153
+ // zero permissions by design — see tests/governance_parity.mjs Part D), and R99 is
1154
+ // a behaviour-identical remodelling of the #919 gate, not a re-gating of it.
1155
+ //
1156
+ // GOVERNANCE OWNS THE POLICY, THE KERNEL STAYS THE ENFORCER. The declaration is
1157
+ // governance-owned data in modules/governance/session-scopes.json, validated
1158
+ // against the permission catalog at load by modules/governance/session-scopes.js.
1159
+ // Core reads it as DATA with fs — never `require` (ADR 0083's one-way rule: a
1160
+ // module's code and enable/disable state must never be something the kernel
1161
+ // depends on). The sibling trust primitive permission-path-check.js reads the
1162
+ // protected-surface registry exactly this way (R101). Reading it statically rather
1163
+ // than through the `governance` kernel port is the deliberate call: a request must
1164
+ // not need a module to be mounted to stay deny-by-default, and the alternative
1165
+ // fails badly in both directions — denying on an absent port would take every
1166
+ // cli/web session down, admitting would hand a box token full authority.
1167
+ //
1168
+ // FAIL-CLOSED both ways: an unreadable or malformed registry THROWS (it never
1169
+ // degrades to "every session is unscoped"), and a session whose `source` the
1170
+ // registry does not declare is DENIED rather than defaulting to full authority. A
1171
+ // null/absent source is a DECLARED row (rows minted before migration 090 added the
1172
+ // column, which predate the 'box' source existing), not an implicit default.
1173
+ const SESSION_SCOPE_REGISTRY_PATH = path.join(__dirname, '..', '..', 'modules', 'governance', 'session-scopes.json');
1174
+
1175
+ // Pure: validate + normalize the parsed registry. Split from the file read so the
1176
+ // shipped assertions are what the tests exercise. `label` identifies the file in
1177
+ // the error text (the fs wrapper passes the path).
1178
+ function parseSessionScopes(raw, label = 'session-scope registry') {
1179
+ const rows = Array.isArray(raw && raw.scopes) ? raw.scopes : null;
1180
+ if (!rows || rows.length === 0) {
1181
+ throw new Error(`${label} declares no session scopes. Refusing to run with no session-scope model (ADR 0053/0151).`);
1182
+ }
1183
+ const bySource = new Map();
1184
+ for (const row of rows) {
1185
+ const source = row && row.source !== undefined ? row.source : null;
1186
+ if (source !== null && (typeof source !== 'string' || !source)) {
1187
+ throw new Error(`${label}: every scope needs a non-empty session source string, or null for the legacy sourceless row`);
1188
+ }
1189
+ if (bySource.has(source)) throw new Error(`${label}: duplicate session source "${source}"`);
1190
+ const unscoped = !!(row && row.unscoped === true);
1191
+ const permissions = row && Array.isArray(row.permissions) ? [...row.permissions] : [];
1192
+ // unscoped XOR an allow-list. Both would be ambiguous (does it narrow?);
1193
+ // neither would be a scope that can never admit anything — an authoring slip
1194
+ // either way, and both must fail loud rather than pick a default.
1195
+ if (unscoped === (permissions.length > 0)) {
1196
+ throw new Error(`${label}: "${source}" must declare EITHER unscoped:true OR a non-empty permissions allow-list, never both/neither`);
1197
+ }
1198
+ if (permissions.some((key) => typeof key !== 'string' || !key)) {
1199
+ throw new Error(`${label}: "${source}" lists a non-string permission key`);
1200
+ }
1201
+ bySource.set(source, Object.freeze({
1202
+ source,
1203
+ label: row && typeof row.label === 'string' ? row.label : null,
1204
+ unscoped,
1205
+ permissions: Object.freeze(permissions),
1206
+ }));
1207
+ }
1208
+ return bySource;
1209
+ }
1210
+
1211
+ function loadSessionScopes(file) {
1212
+ let raw;
1213
+ try {
1214
+ raw = JSON.parse(fs.readFileSync(file, 'utf8'));
1215
+ } catch (e) {
1216
+ throw new Error(`session-scope registry unreadable at ${file}: ${e.message}. Refusing to run with no session-scope model (ADR 0053/0151).`);
1217
+ }
1218
+ return parseSessionScopes(raw, `session-scope registry ${file}`);
1219
+ }
1220
+
1221
+ const _sessionScopes = loadSessionScopes(SESSION_SCOPE_REGISTRY_PATH);
1222
+ const SESSION_SCOPES = Object.freeze([..._sessionScopes.values()]);
1223
+
1224
+ // The scope a session's `source` declares, or null when the registry does not
1225
+ // declare it (⇒ deny; never "full").
1226
+ function sessionScopeFor(source) {
1227
+ const key = source === undefined ? null : source;
1228
+ if (key !== null && typeof key !== 'string') return null;
1229
+ return _sessionScopes.get(key) || null;
1230
+ }
1231
+
1232
+ // Route reachability, deny-by-default. An unscoped session reaches everything its
1233
+ // builder's gates admit; a SCOPED session reaches only a route that declared
1234
+ // capability its allow-list names — so a route that declares nothing (every route
1235
+ // that has not opted in, including any added later) is out of reach.
1236
+ function scopeCovers(scope, declared) {
1237
+ if (!scope) return false; // unrecognised source
1238
+ if (scope.unscoped) return true;
1239
+ const needs = Array.isArray(declared) ? declared : [];
1240
+ return needs.length > 0 && needs.every((key) => scope.permissions.includes(key));
661
1241
  }
1242
+ function sessionScopeAdmits(source, declared) {
1243
+ return scopeCovers(sessionScopeFor(source), declared);
1244
+ }
1245
+
1246
+ // The route-side DECLARATION: "reaching this route needs these capabilities from
1247
+ // the session's scope." Mount it BEFORE requireBuilder. Inert for an unscoped
1248
+ // session — requireBuilder compares the declaration only against a narrowing scope.
1249
+ function sessionScope(...keys) {
1250
+ const needs = Object.freeze([...keys]);
1251
+ return function declareSessionScope(req, _res, next) {
1252
+ req.sessionScopeNeeds = needs;
1253
+ next();
1254
+ };
1255
+ }
1256
+
1257
+ // The dev-box bootstrap declaration (#919) — the name modules/dev-box mounts
1258
+ // through the plugin API (docs/modules-contract.md 1.2.0), kept as the module-
1259
+ // facing spelling. It is no longer a bare boolean: it declares the box scope's OWN
1260
+ // allow-list, read from the registry, so the declaration cannot drift from the
1261
+ // scope it declares. If the registry stopped declaring a box scope it would
1262
+ // declare nothing and every box session would be denied — fail closed.
1263
+ const allowBoxScope = sessionScope(...((sessionScopeFor('box') || { permissions: [] }).permissions));
662
1264
 
663
1265
  async function requireBuilder(req, res, next) {
664
1266
  const session = await resolveSession(req);
@@ -678,18 +1280,25 @@ async function requireBuilder(req, res, next) {
678
1280
  res.fail('builder_inactive', 403);
679
1281
  return;
680
1282
  }
681
- // SECURITY (#919, SR#2): box-scoped sessions are DENY-BY-DEFAULT. The dev-box
682
- // provisioner (scripts/gds/box.js) bakes a session into cloud-init so the box can
683
- // bootstrap (#818 — box-source-fetch clones the rank-scoped repo before
684
- // /builder-setup exists). That token lives ON the box, which is OUTSIDE the ADR
685
- // 0016 trust boundary, so it must NOT carry the builder's full authority. A
686
- // source='box' session may reach ONLY the routes that opted in via allowBoxScope
687
- // (the 4 endpoints the on-box crons call — see routes/box.js); EVERY other route
688
- // (all of /tasks, /me, the rest of /box …) fails closed here, including any route
689
- // added later. A builder who wants to do real work FROM the box runs
690
- // /builder-reauth, minting a normal cli/web session (no box scope) like any laptop.
691
- if (session.source === 'box' && !req.allowBoxScope) {
692
- res.fail('box_scope', { status: 403, message: 'This is a box-scoped session; it may only call dev-box bootstrap endpoints.' });
1283
+ // SECURITY (#919, SR#2): SESSION-SCOPE, deny-by-default. The dev-box provisioner
1284
+ // (scripts/gds/box.js) bakes a session into cloud-init so the box can bootstrap
1285
+ // (#818 — box-source-fetch clones the rank-scoped repo before /builder-setup
1286
+ // exists). That token lives ON the box, which is OUTSIDE the ADR 0016 trust
1287
+ // boundary, so it must NOT carry the builder's full authority. Since R99 the rule
1288
+ // is DATA (the session-scope registry above), not a hardcoded source test: a
1289
+ // narrowing session reaches ONLY a route that declared capability its allow-list
1290
+ // names, so EVERY other route (all of /tasks, /me, the rest of /box …) fails
1291
+ // closed here, including any route added later; and a source the registry does not
1292
+ // declare at all is denied rather than treated as unscoped. A builder who wants to
1293
+ // do real work FROM the box runs /builder-reauth, minting a normal cli/web session.
1294
+ const scope = sessionScopeFor(session.source);
1295
+ if (!scopeCovers(scope, req.sessionScopeNeeds)) {
1296
+ res.fail('box_scope', {
1297
+ status: 403,
1298
+ message: scope
1299
+ ? 'This is a box-scoped session; it may only call dev-box bootstrap endpoints.'
1300
+ : 'This session carries an unrecognised scope and may not call authenticated endpoints.',
1301
+ });
693
1302
  return;
694
1303
  }
695
1304
  // Anthropic-identity pin — REMOVED (task 1039, idea 258). This used to 403
@@ -729,17 +1338,6 @@ async function requireBuilder(req, res, next) {
729
1338
  next();
730
1339
  }
731
1340
 
732
- // Page-level sign-in gate for the authenticated /builders HTML surface and its
733
- // content assets (#488). Same identity check as requireBuilder — token from the
734
- // gds_session cookie (or legacy pms_session / Bearer), looked up fresh against
735
- // builder_sessions — but on failure it REDIRECTS the browser into the web
736
- // sign-in flow (302 → /api/gds/auth/web/start) instead of returning a JSON 401,
737
- // because these requests are page navigations and asset loads, not API calls.
738
- //
739
- // This is the *actual* access control for the citizen primer + system-maps:
740
- // server.js mounts it ahead of the static handlers so an unauthenticated
741
- // request is bounced before the file is ever served (curl-proof, JS-disabled-
742
- // proof). The client-side link-hiding in builders.js is only cosmetic UX.
743
1341
  // Build a `?return=<absolute current URL>` for a page-gate sign-in redirect
744
1342
  // (#734) so a builder bounced into sign-in from the builders subdomain lands
745
1343
  // back on the page they wanted. The web flow validates it (open-redirect guard)
@@ -753,87 +1351,114 @@ function signInWithReturn(req) {
753
1351
  return `${base}?return=${encodeURIComponent(`${proto}://${host}${req.originalUrl}`)}`;
754
1352
  }
755
1353
 
756
- async function requireBuilderPage(req, res, next) {
1354
+ // ── the three PAGE GATES (BV1.R107, ADR 0151) ────────────────────────────────
1355
+ //
1356
+ // A page gate is the *actual* access control for an HTML surface and its content
1357
+ // assets (#488): serve-internal.js mounts it ahead of the static handlers, so an
1358
+ // unauthorized request is bounced before the file is ever served (curl-proof,
1359
+ // JS-disabled-proof). The client-side link-hiding in the hall is cosmetic UX.
1360
+ //
1361
+ // Each gate asks TWO questions, in order:
1362
+ // 1. IDENTITY — is there a live session? (token from the gds_session cookie /
1363
+ // legacy pms_session / Bearer, looked up fresh against builder_sessions).
1364
+ // No → 302 into the web sign-in flow.
1365
+ // 2. AUTHORITY — does the builder HOLD the page's catalog permission, resolved
1366
+ // per request through the `governance` port exactly as requirePermission
1367
+ // does? No → 302 to an UNGATED hall page.
1368
+ //
1369
+ // They REDIRECT rather than res.fail() because these are page navigations and
1370
+ // asset loads, not API calls — a JSON 403 renders as a blank error body in the
1371
+ // browser instead of sending the visitor somewhere they can act.
1372
+ //
1373
+ // R107 replaced each gate's hand-rolled rank test with the catalog key sitting at
1374
+ // that gate's EXISTING rank floor, so the audience is unchanged over the four live
1375
+ // 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
1379
+ // The one accepted divergence is the one governance_002 already documents
1380
+ // (tests/governance_parity.mjs Part D): a builder on a reserved/divine rank holds
1381
+ // no seeded role, so they now fail a page gate a bare rank test would have
1382
+ // admitted. That is a TIGHTENING, never a widening.
1383
+ //
1384
+ // `deniedTo` is NEVER the sign-in flow. A visitor on that branch already HAS a
1385
+ // session, so bouncing them into sign-in returns them to the same gate — an
1386
+ // 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.
1389
+ // - /builders/not-ready the friendly notice for the orientation reading rooms;
1390
+ // it shows the rank ladder and a way back to the hall.
1391
+ const PAGE_GATES = Object.freeze({
1392
+ requireBuilderPage: Object.freeze({ permission: 'page.view.public', deniedTo: '/builders' }),
1393
+ requireNonXenosPage: Object.freeze({ permission: 'page.view.builder', deniedTo: '/builders/not-ready' }),
1394
+ requireArchonPage: Object.freeze({ permission: 'page.view.governance', deniedTo: '/builders' }),
1395
+ });
1396
+
1397
+ // A builder's effective permission set, or NULL when authority cannot be
1398
+ // ESTABLISHED (the `governance` port is absent or malformed, or the resolver
1399
+ // threw). Every caller must read null as a DENIAL: admitting when the authority
1400
+ // system is missing would make disabling a module a privilege escalation — the
1401
+ // inversion ADR 0016 exists to prevent. Resolved per request, never memoised.
1402
+ async function resolveHeldPermissions(builderId) {
1403
+ if (builderId === null || builderId === undefined || builderId === '') return null;
1404
+ const governance = seams.resolveOptional('governance');
1405
+ if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
757
1406
  try {
758
- const session = await resolveSession(req);
759
- if (!session) {
760
- res.redirect(302, signInWithReturn(req));
761
- return;
762
- }
763
- next();
1407
+ return new Set((await governance.resolveBuilderPermissions(builderId)) || []);
764
1408
  } catch (err) {
765
- // Fail CLOSED: if the session lookup itself errors (e.g. DB blip), send the
766
- // visitor to sign-in rather than leaking the gated page. The content the
767
- // gate protects is the priority here, not availability.
768
- console.error('[gds] requireBuilderPage lookup failed', err);
769
- res.redirect(302, signInWithReturn(req));
1409
+ console.error('[gds] permission resolve failed', err);
1410
+ return null;
770
1411
  }
771
1412
  }
772
1413
 
773
- // requireArchonPage — page-level Archon gate for the /watch monitoring surface
774
- // (#726). Same identity resolution as requireBuilderPage, but additionally
775
- // checks the LIVE rank from the session lookup:
776
- // - no session → 302 into the web sign-in flow (page navigation)
777
- // - signed in, not Archon → 302 back to the hall (/builders) — they ARE a
778
- // citizen, just not the Ruling Hand; bouncing to
779
- // sign-in would loop, so send them home instead.
780
- // - Archon → next()
781
- // This is defense-in-depth: every data endpoint the page calls is ALSO
782
- // requireRank('archon') server-side, so no cross-builder data can leak even if
783
- // this gate were bypassed. But it keeps a non-Archon from ever seeing the page
784
- // shell, honoring "only Archons have access".
785
- async function requireArchonPage(req, res, next) {
1414
+ async function runPageGate(name, req, res, next) {
1415
+ const { permission, deniedTo } = PAGE_GATES[name];
1416
+ let session;
786
1417
  try {
787
- const session = await resolveSession(req);
788
- if (!session) {
789
- res.redirect(302, signInWithReturn(req));
790
- return;
791
- }
792
- if (session.rank !== 'archon') {
793
- res.redirect(302, '/builders');
794
- return;
795
- }
796
- next();
1418
+ session = await resolveSession(req);
797
1419
  } catch (err) {
798
- // Fail CLOSED — a lookup error sends the visitor to sign-in rather than
799
- // leaking the gated page shell.
800
- console.error('[gds] requireArchonPage lookup failed', err);
1420
+ // Fail CLOSED: a session-lookup blip sends the visitor to sign-in rather than
1421
+ // leaking the gated page. The content the gate protects is the priority, not
1422
+ // availability — and at this point we cannot tell whether they were signed in.
1423
+ console.error(`[gds] ${name} lookup failed`, err);
801
1424
  res.redirect(302, signInWithReturn(req));
1425
+ return;
1426
+ }
1427
+ if (!session) {
1428
+ res.redirect(302, signInWithReturn(req));
1429
+ return;
1430
+ }
1431
+ const held = await resolveHeldPermissions(session.builder_id);
1432
+ if (held === null || !held.has(permission)) {
1433
+ res.redirect(302, deniedTo);
1434
+ return;
802
1435
  }
1436
+ next();
1437
+ }
1438
+
1439
+ // The authenticated /builders HTML surface — the Work Board, Roadmap, Goals, the
1440
+ // task-detail page, the Repo Atlas, the Dev Box pair page and Settings. A Xenos IS
1441
+ // allowed (page.view.public floors at xenos); an unauthenticated visitor is bounced
1442
+ // into sign-in rather than served the page shell.
1443
+ async function requireBuilderPage(req, res, next) {
1444
+ return runPageGate('requireBuilderPage', req, res, next);
1445
+ }
1446
+
1447
+ // 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);
803
1454
  }
804
1455
 
805
- // requireNonXenosPage — page-level gate for the orientation reading rooms (the
806
- // citizen's primer, the system-maps/diagrams) that #766 closed to newcomers
807
- // and #770 enforces at the URL. A Xenos's path is action-only — find work,
808
- // claim, ship three — so these pages are reserved for graduated citizens
809
- // (Thetes+). Same identity resolution as requireBuilderPage, plus a live-rank
810
- // check:
811
- // - no session → 302 into the web sign-in flow (page navigation)
812
- // - Xenos → 302 to /builders/not-ready — they ARE a citizen, just not
813
- // ready for the reading rooms; the not-ready page offers the
814
- // rank ladder + a way back to the hall. Bouncing to sign-in
815
- // would loop, so send them to the friendly notice.
816
- // - Thetes+ → next()
817
- // Defense-in-depth: this is the curl-proof / no-JS-proof gate. The link-hiding
818
- // in builders.js is only cosmetic.
1456
+ // The orientation reading rooms (the citizen's primer, the system-maps/diagrams)
1457
+ // that #766 closed to newcomers and #770 enforces at the URL. A Xenos's path is
1458
+ // action-only — find work, claim, ship three — so these pages are reserved for
1459
+ // graduated citizens (Thetes+).
819
1460
  async function requireNonXenosPage(req, res, next) {
820
- try {
821
- const session = await resolveSession(req);
822
- if (!session) {
823
- res.redirect(302, signInWithReturn(req));
824
- return;
825
- }
826
- if (session.rank === 'xenos') {
827
- res.redirect(302, '/builders/not-ready');
828
- return;
829
- }
830
- next();
831
- } catch (err) {
832
- // Fail CLOSED — a lookup error sends the visitor to sign-in rather than
833
- // leaking the gated page shell.
834
- console.error('[gds] requireNonXenosPage lookup failed', err);
835
- res.redirect(302, signInWithReturn(req));
836
- }
1461
+ return runPageGate('requireNonXenosPage', req, res, next);
837
1462
  }
838
1463
 
839
1464
  // ---------------------------------------------------------------------------
@@ -931,6 +1556,63 @@ function requireRank(...allowed) {
931
1556
  };
932
1557
  }
933
1558
 
1559
+ // requirePermission(...perms) — the PERMISSION gate (ADR 0151, BV1.R97).
1560
+ //
1561
+ // The permissions-as-atom sibling of requireRank. Instead of asking "is this
1562
+ // builder's rank at-or-above a floor", it asks "does this builder HOLD these
1563
+ // permission atoms", resolved from the roles they hold. R93's compat seed derives
1564
+ // the four rank-roles from each permission's `floor`, so on day one the two gates
1565
+ // are EQUIVALENT — requirePermission('idea.triage') admits exactly who
1566
+ // requireRank('metic','archon') admits. R104-R107 migrate the ~81 existing call
1567
+ // sites; requireRank itself is untouched and keeps working throughout.
1568
+ //
1569
+ // AND, not OR — every named permission must be held. requireRank(...allowed) is
1570
+ // OR-ish only because its arguments are points on ONE ladder (the gate's floor is
1571
+ // the lowest of them); unordered atoms have no such ordering to collapse. The
1572
+ // governance port's builderHasPermissions() already reads "holds EVERY one", and
1573
+ // AND is the stricter, less surprising reading of requirePermission('a','b'): a
1574
+ // gate naming two authorities demands both. A genuine "either of these" gate
1575
+ // should be spelled as its own permission key, not smuggled into the gate's arity.
1576
+ //
1577
+ // FAIL-CLOSED at every branch — 403, never an admit:
1578
+ // • no req.builder (gate mounted without requireBuilder) → 403
1579
+ // • no perms named (a mis-authored gate) → 403
1580
+ // • the `governance` port is not registered (module off) → 403
1581
+ // • the resolver throws (DB blip) → 403
1582
+ // The port-absent branch is the deliberate one. `governance` is a core-domain,
1583
+ // default:true module, so its absence means authority cannot be ESTABLISHED —
1584
+ // and admitting on "the authority system is missing" would make disabling a
1585
+ // module a privilege escalation, the exact inversion ADR 0016 exists to prevent.
1586
+ // A misconfigured instance loses its gated routes; it never gives them away.
1587
+ // (Hence resolveOptional + an explicit 403 rather than seams.resolve()'s throw,
1588
+ // which would surface as a 500 and read as an outage instead of a denial.)
1589
+ //
1590
+ // UNCACHED — resolved on the request that needs it, exactly as requireRank reads
1591
+ // the live rank. No memo here and none in resolver.js (ADR 0016).
1592
+ //
1593
+ // The 403 body mirrors requireRank's: `required` is the literal permission list
1594
+ // the gate names, and `held` is the subset of THOSE the builder actually holds —
1595
+ // or null when the set could not be resolved at all (the requireRank `actual ??
1596
+ // null` analogue). Reporting only the intersection explains the denial without
1597
+ // dumping the builder's entire permission set into an error body.
1598
+ //
1599
+ // Usage: router.post('/roles', auth.requireBuilder, auth.requirePermission('governance.manage'), handler)
1600
+ function requirePermission(...perms) {
1601
+ const required = Object.freeze([...perms]);
1602
+ return async function permissionGate(req, res, next) {
1603
+ const deny = (held) => res.fail('permission_forbidden', 403, { required, held });
1604
+ const builderId = req.builder && req.builder.id;
1605
+ if (!builderId) return deny(null);
1606
+ if (required.length === 0) return deny(null);
1607
+ // Shared with the page gates (R107): null = authority could not be established
1608
+ // (no port / malformed port / a throwing resolver) and is always a denial.
1609
+ const held = await resolveHeldPermissions(builderId);
1610
+ if (held === null) return deny(null);
1611
+ if (required.every((key) => held.has(key))) return next();
1612
+ return deny(required.filter((key) => held.has(key)));
1613
+ };
1614
+ }
1615
+
934
1616
  // requireBfgPrincipal — the identity gate for the cross-builder memory WRITE
935
1617
  // path (ADR 0026 §6C / task #446). Runs AFTER requireBuilder. This is
936
1618
  // DELIBERATELY NOT a rank gate: the trust-boundary exception is bound to ONE
@@ -969,23 +1651,43 @@ function requireDiscordBot(req, res, next) {
969
1651
 
970
1652
  module.exports = {
971
1653
  authConfigured,
1654
+ idpEnabled,
1655
+ loadIdpConfig,
1656
+ idpConfigured,
972
1657
  openEnrollmentEnabled,
973
1658
  firstTimeAdmissionBlocked,
1659
+ webAdmissionStatus,
974
1660
  firstAdminLogin,
975
1661
  maybeSeatFirstAdmin,
976
1662
  startDeviceFlow,
977
1663
  pollDeviceFlow,
978
1664
  buildAuthorizeUrl,
1665
+ scopeForWebFlow,
1666
+ buildIdpAuthorizeUrl,
1667
+ idpAuthorizeUrl,
979
1668
  completeWebFlow,
1669
+ completeIdpFlow,
1670
+ loadHubPublicKey,
1671
+ verifyHubAssertion,
1672
+ verifyHubLogoutToken,
1673
+ logoutTokenJtiSeenAndRecord,
1674
+ completeDeviceExchange,
1675
+ membershipRole,
980
1676
  extractTokens,
981
1677
  resolveSession,
982
1678
  allowBoxScope,
1679
+ sessionScope,
1680
+ sessionScopeFor,
1681
+ sessionScopeAdmits,
1682
+ parseSessionScopes,
1683
+ SESSION_SCOPES,
983
1684
  requireBuilder,
984
1685
  requireBuilderPage,
985
1686
  requireArchonPage,
986
1687
  requireNonXenosPage,
987
1688
  requireRank,
988
1689
  rankMeetsThreshold,
1690
+ requirePermission,
989
1691
  requireBfgPrincipal,
990
1692
  requireDiscordBot,
991
1693
  // Discord OAuth (account-linking — Phase 3 F2, ADR 0033)