@bongos/core 1.18.3 → 1.18.19

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 (471) hide show
  1. package/.bongos-core.json +1576 -286
  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 +4 -2
  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 +6 -1
  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/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/migrate.sh +99 -14
  347. package/scripts/status-mirror-sync.js +114 -84
  348. package/src/bongos/api-errors.js +1 -0
  349. package/src/bongos/auth.js +447 -102
  350. package/src/bongos/db-kernel.js +27 -0
  351. package/src/bongos/db.js +39 -0
  352. package/src/bongos/module-scope-map.js +27 -1
  353. package/src/bongos/permission-path-check.js +136 -102
  354. package/src/bongos/repo-info.js +47 -11
  355. package/src/bongos/route-rank-check.js +429 -46
  356. package/src/bongos/routes/_helpers.js +12 -1
  357. package/src/bongos/routes/auth.js +219 -2
  358. package/src/bongos/routes/backup.js +58 -2
  359. package/src/bongos/routes/me.js +35 -1
  360. package/src/bongos/serve-internal.js +84 -15
  361. package/src/branding.js +32 -0
  362. package/src/instance-config.js +30 -0
  363. package/src/module-api.js +124 -1
  364. package/src/platform-server.js +86 -9
  365. package/tests/abandon_task.mjs +59 -3
  366. package/tests/api_cli_flags.mjs +53 -0
  367. package/tests/auth_page_gate.mjs +13 -0
  368. package/tests/backfill_sessions.mjs +127 -1
  369. package/tests/backup_cli.mjs +54 -0
  370. package/tests/bongos_box_connect.mjs +77 -0
  371. package/tests/box_access.mjs +130 -1
  372. package/tests/box_credential.mjs +125 -0
  373. package/tests/box_host_keys.mjs +24 -0
  374. package/tests/box_infra_resolve.mjs +149 -0
  375. package/tests/box_scope_session.mjs +6 -2
  376. package/tests/box_sync.mjs +55 -0
  377. package/tests/box_task_scope.mjs +99 -0
  378. package/tests/branding.mjs +12 -0
  379. package/tests/cf_ruleset_preflight.mjs +142 -0
  380. package/tests/cf_tunnel.mjs +37 -0
  381. package/tests/claim_gate_rebase.mjs +98 -0
  382. package/tests/clawback_confirmed_backlog.mjs +103 -0
  383. package/tests/cli_lib_formatters.mjs +20 -0
  384. package/tests/confirm_attribution.mjs +30 -46
  385. package/tests/credit_grant.mjs +198 -10
  386. package/tests/criterion_authoring_gate.mjs +272 -0
  387. package/tests/criterion_progress.mjs +33 -1
  388. package/tests/dev_lib.mjs +253 -0
  389. package/tests/devbox_artifact_gate.mjs +75 -0
  390. package/tests/devbox_dmg_backdrop.mjs +137 -0
  391. package/tests/devbox_icons.mjs +304 -0
  392. package/tests/devbox_installer_brand.mjs +232 -0
  393. package/tests/devbox_key_register.mjs +234 -0
  394. package/tests/docs_openapi_route.mjs +110 -0
  395. package/tests/doctor_hooks.mjs +141 -0
  396. package/tests/extract_json.mjs +140 -0
  397. package/tests/fitness.mjs +89 -0
  398. package/tests/gen_freshness.mjs +96 -0
  399. package/tests/goal_advisory.mjs +251 -0
  400. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  401. package/tests/goal_routes.mjs +59 -4
  402. package/tests/goal_tier.mjs +12 -3
  403. package/tests/governance_catalog.mjs +129 -0
  404. package/tests/governance_claim_eligibility.mjs +262 -0
  405. package/tests/governance_config.mjs +187 -0
  406. package/tests/governance_ownership_scope.mjs +410 -0
  407. package/tests/governance_page_gates.mjs +324 -0
  408. package/tests/governance_parity.mjs +336 -0
  409. package/tests/governance_principals.mjs +232 -0
  410. package/tests/governance_protected_surfaces.mjs +208 -0
  411. package/tests/governance_require_permission.mjs +223 -0
  412. package/tests/governance_resolver.mjs +84 -0
  413. package/tests/governance_role_sync.mjs +231 -0
  414. package/tests/governance_seed.mjs +127 -0
  415. package/tests/governance_session_scope.mjs +434 -0
  416. package/tests/governance_vanilla_boot.mjs +203 -0
  417. package/tests/grade_attribution.mjs +7 -7
  418. package/tests/hall_audit.mjs +223 -0
  419. package/tests/hall_nav.mjs +191 -0
  420. package/tests/hall_palette.mjs +206 -0
  421. package/tests/hall_signout.mjs +124 -0
  422. package/tests/hook_user_agents.mjs +10 -0
  423. package/tests/idea_triage.mjs +162 -0
  424. package/tests/idp_device_exchange.mjs +93 -0
  425. package/tests/init.mjs +51 -0
  426. package/tests/instance_config.mjs +65 -0
  427. package/tests/instance_mismatch_guard.mjs +23 -6
  428. package/tests/instance_release_config.mjs +104 -0
  429. package/tests/linkify_refs.mjs +15 -2
  430. package/tests/migrate_root_resolution.mjs +124 -1
  431. package/tests/module_contributions.mjs +55 -0
  432. package/tests/module_loader.mjs +10 -5
  433. package/tests/module_scope_active_claims.mjs +94 -0
  434. package/tests/needs_rebase.mjs +92 -0
  435. package/tests/one_active_claim_per_session.mjs +76 -0
  436. package/tests/override_attribution.mjs +10 -8
  437. package/tests/override_request_ownership.mjs +8 -4
  438. package/tests/parse_cookie_decode.mjs +42 -0
  439. package/tests/pay_on_land.mjs +129 -0
  440. package/tests/platform_boot.mjs +100 -1
  441. package/tests/post_login_landing.mjs +42 -0
  442. package/tests/preflight_checks.mjs +72 -0
  443. package/tests/provision.mjs +132 -7
  444. package/tests/publish_branch_route.mjs +53 -0
  445. package/tests/publish_reconciler.mjs +187 -2
  446. package/tests/repo_info_binding.mjs +248 -0
  447. package/tests/reward_gate.mjs +11 -2
  448. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  449. package/tests/reward_mode_surfacing.mjs +168 -0
  450. package/tests/route_permission_check.mjs +301 -0
  451. package/tests/route_rank_check.mjs +254 -12
  452. package/tests/route_validation.mjs +30 -0
  453. package/tests/sequence.mjs +267 -0
  454. package/tests/session_digest.mjs +44 -0
  455. package/tests/session_index.mjs +18 -6
  456. package/tests/session_records.mjs +39 -1
  457. package/tests/session_reward_on_land.mjs +381 -0
  458. package/tests/session_reward_reconcile.mjs +229 -0
  459. package/tests/ship_api_client_pathspec.mjs +100 -0
  460. package/tests/ship_base_flag.mjs +93 -0
  461. package/tests/ship_ci_deploy.mjs +80 -3
  462. package/tests/ship_upstream_deletion.mjs +57 -0
  463. package/tests/smoke_target.mjs +233 -0
  464. package/tests/start_env_guard.mjs +87 -0
  465. package/tests/start_rebase_warning.mjs +103 -0
  466. package/tests/status_mirror_sync.mjs +122 -0
  467. package/tests/stop_refcheck.mjs +19 -1
  468. package/tests/task_goal_link.mjs +15 -6
  469. package/tests/upgrade.mjs +139 -0
  470. package/tests/web_admission_status.mjs +162 -0
  471. package/tests/write_validation_gaps.mjs +110 -0
@@ -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');
@@ -250,6 +251,48 @@ function firstTimeAdmissionBlocked({ existing, approved, login }) {
250
251
  return true;
251
252
  }
252
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
+
253
296
  // First-admin bootstrap (R62 / task 1201, ADR 0062). The configured firstAdmin
254
297
  // login — an install-time env override (<PREFIX>_FIRST_ADMIN, incl. legacy) wins,
255
298
  // else branding.firstAdmin. Empty/unset (the vanilla starter) means "no one is
@@ -284,6 +327,34 @@ async function maybeSeatFirstAdmin(builder) {
284
327
  return builder;
285
328
  }
286
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
+
287
358
  function newSessionToken() {
288
359
  return crypto.randomBytes(32).toString('hex');
289
360
  }
@@ -467,6 +538,7 @@ async function pollDeviceFlow(deviceCode, userAgent) {
467
538
  avatarUrl: ghUser.avatar_url,
468
539
  });
469
540
  builder = await maybeSeatFirstAdmin(builder);
541
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
470
542
  const token = newSessionToken();
471
543
  await createSession({
472
544
  builderId: builder.id,
@@ -530,6 +602,13 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
530
602
  if (firstTimeAdmissionBlocked({ existing, approved, login: ghUser.login })) {
531
603
  const err = new Error('not_approved');
532
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;
533
612
  throw err;
534
613
  }
535
614
  }
@@ -540,6 +619,7 @@ async function completeWebFlow({ code, redirectUri, userAgent, captureRepoToken
540
619
  avatarUrl: ghUser.avatar_url,
541
620
  });
542
621
  builder = await maybeSeatFirstAdmin(builder);
622
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
543
623
  const token = newSessionToken();
544
624
  // Web sessions expire after 30 days; the user re-auths on the page
545
625
  // when the cookie expires.
@@ -644,6 +724,7 @@ async function completeIdpFlow({ code, redirectUri, userAgent: ua }) {
644
724
  avatarUrl: identity.avatar_url,
645
725
  });
646
726
  builder = await maybeSeatFirstAdmin(builder);
727
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
647
728
  const token = newSessionToken();
648
729
  const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
649
730
  await createSession({
@@ -712,6 +793,45 @@ function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } =
712
793
  return payload;
713
794
  }
714
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
+
715
835
  // Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
716
836
  // aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
717
837
  // assertion's own expiry, so a captured assertion can't be replayed into multiple
@@ -726,6 +846,25 @@ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
726
846
  return false;
727
847
  }
728
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
+
729
868
  // Complete a CLI federation exchange: verify the hub assertion, then run the SAME
730
869
  // admission gate + upsertBuilder + createSession(source='cli') as the direct device
731
870
  // flow (pollDeviceFlow). Only the identity SOURCE differs. Returns { token, builder }.
@@ -757,6 +896,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
757
896
  avatarUrl: payload.avatar_url,
758
897
  });
759
898
  builder = await maybeSeatFirstAdmin(builder);
899
+ await safeSyncRankRole(builder); // BV1.R95b — the seeded rank-role must exist from the first sign-in
760
900
  const token = newSessionToken();
761
901
  await createSession({
762
902
  builderId: builder.id,
@@ -994,18 +1134,134 @@ async function resolveSession(req) {
994
1134
  return null;
995
1135
  }
996
1136
 
997
- // SECURITY (#919): opt-in marker for the handful of dev-box bootstrap routes a
998
- // box-scoped session (source='box') is permitted to reach. Mount it BEFORE
999
- // requireBuilder on those routes; it only flags the request, and requireBuilder's
1000
- // deny-by-default box-scope gate reads the flag. A route WITHOUT this middleware
1001
- // rejects a box session, so this allow-list IS the complete box surface and any
1002
- // new route is denied to boxes automatically (fail closed). No-op for cli/web
1003
- // sessions — requireBuilder only consults the flag when source === 'box'.
1004
- function allowBoxScope(req, _res, next) {
1005
- req.allowBoxScope = true;
1006
- 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));
1241
+ }
1242
+ function sessionScopeAdmits(source, declared) {
1243
+ return scopeCovers(sessionScopeFor(source), declared);
1007
1244
  }
1008
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));
1264
+
1009
1265
  async function requireBuilder(req, res, next) {
1010
1266
  const session = await resolveSession(req);
1011
1267
  if (!session) {
@@ -1024,18 +1280,25 @@ async function requireBuilder(req, res, next) {
1024
1280
  res.fail('builder_inactive', 403);
1025
1281
  return;
1026
1282
  }
1027
- // SECURITY (#919, SR#2): box-scoped sessions are DENY-BY-DEFAULT. The dev-box
1028
- // provisioner (scripts/gds/box.js) bakes a session into cloud-init so the box can
1029
- // bootstrap (#818 — box-source-fetch clones the rank-scoped repo before
1030
- // /builder-setup exists). That token lives ON the box, which is OUTSIDE the ADR
1031
- // 0016 trust boundary, so it must NOT carry the builder's full authority. A
1032
- // source='box' session may reach ONLY the routes that opted in via allowBoxScope
1033
- // (the 4 endpoints the on-box crons call — see routes/box.js); EVERY other route
1034
- // (all of /tasks, /me, the rest of /box …) fails closed here, including any route
1035
- // added later. A builder who wants to do real work FROM the box runs
1036
- // /builder-reauth, minting a normal cli/web session (no box scope) like any laptop.
1037
- if (session.source === 'box' && !req.allowBoxScope) {
1038
- 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
+ });
1039
1302
  return;
1040
1303
  }
1041
1304
  // Anthropic-identity pin — REMOVED (task 1039, idea 258). This used to 403
@@ -1075,17 +1338,6 @@ async function requireBuilder(req, res, next) {
1075
1338
  next();
1076
1339
  }
1077
1340
 
1078
- // Page-level sign-in gate for the authenticated /builders HTML surface and its
1079
- // content assets (#488). Same identity check as requireBuilder — token from the
1080
- // gds_session cookie (or legacy pms_session / Bearer), looked up fresh against
1081
- // builder_sessions — but on failure it REDIRECTS the browser into the web
1082
- // sign-in flow (302 → /api/gds/auth/web/start) instead of returning a JSON 401,
1083
- // because these requests are page navigations and asset loads, not API calls.
1084
- //
1085
- // This is the *actual* access control for the citizen primer + system-maps:
1086
- // server.js mounts it ahead of the static handlers so an unauthenticated
1087
- // request is bounced before the file is ever served (curl-proof, JS-disabled-
1088
- // proof). The client-side link-hiding in builders.js is only cosmetic UX.
1089
1341
  // Build a `?return=<absolute current URL>` for a page-gate sign-in redirect
1090
1342
  // (#734) so a builder bounced into sign-in from the builders subdomain lands
1091
1343
  // back on the page they wanted. The web flow validates it (open-redirect guard)
@@ -1099,87 +1351,114 @@ function signInWithReturn(req) {
1099
1351
  return `${base}?return=${encodeURIComponent(`${proto}://${host}${req.originalUrl}`)}`;
1100
1352
  }
1101
1353
 
1102
- 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;
1103
1406
  try {
1104
- const session = await resolveSession(req);
1105
- if (!session) {
1106
- res.redirect(302, signInWithReturn(req));
1107
- return;
1108
- }
1109
- next();
1407
+ return new Set((await governance.resolveBuilderPermissions(builderId)) || []);
1110
1408
  } catch (err) {
1111
- // Fail CLOSED: if the session lookup itself errors (e.g. DB blip), send the
1112
- // visitor to sign-in rather than leaking the gated page. The content the
1113
- // gate protects is the priority here, not availability.
1114
- console.error('[gds] requireBuilderPage lookup failed', err);
1115
- res.redirect(302, signInWithReturn(req));
1409
+ console.error('[gds] permission resolve failed', err);
1410
+ return null;
1116
1411
  }
1117
1412
  }
1118
1413
 
1119
- // requireArchonPage — page-level Archon gate for the /watch monitoring surface
1120
- // (#726). Same identity resolution as requireBuilderPage, but additionally
1121
- // checks the LIVE rank from the session lookup:
1122
- // - no session → 302 into the web sign-in flow (page navigation)
1123
- // - signed in, not Archon → 302 back to the hall (/builders) — they ARE a
1124
- // citizen, just not the Ruling Hand; bouncing to
1125
- // sign-in would loop, so send them home instead.
1126
- // - Archon → next()
1127
- // This is defense-in-depth: every data endpoint the page calls is ALSO
1128
- // requireRank('archon') server-side, so no cross-builder data can leak even if
1129
- // this gate were bypassed. But it keeps a non-Archon from ever seeing the page
1130
- // shell, honoring "only Archons have access".
1131
- async function requireArchonPage(req, res, next) {
1414
+ async function runPageGate(name, req, res, next) {
1415
+ const { permission, deniedTo } = PAGE_GATES[name];
1416
+ let session;
1132
1417
  try {
1133
- const session = await resolveSession(req);
1134
- if (!session) {
1135
- res.redirect(302, signInWithReturn(req));
1136
- return;
1137
- }
1138
- if (session.rank !== 'archon') {
1139
- res.redirect(302, '/builders');
1140
- return;
1141
- }
1142
- next();
1418
+ session = await resolveSession(req);
1143
1419
  } catch (err) {
1144
- // Fail CLOSED — a lookup error sends the visitor to sign-in rather than
1145
- // leaking the gated page shell.
1146
- 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);
1424
+ res.redirect(302, signInWithReturn(req));
1425
+ return;
1426
+ }
1427
+ if (!session) {
1147
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;
1148
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);
1149
1445
  }
1150
1446
 
1151
- // requireNonXenosPage — page-level gate for the orientation reading rooms (the
1152
- // citizen's primer, the system-maps/diagrams) that #766 closed to newcomers
1153
- // and #770 enforces at the URL. A Xenos's path is action-only — find work,
1154
- // claim, ship three — so these pages are reserved for graduated citizens
1155
- // (Thetes+). Same identity resolution as requireBuilderPage, plus a live-rank
1156
- // check:
1157
- // - no session → 302 into the web sign-in flow (page navigation)
1158
- // - Xenos → 302 to /builders/not-ready — they ARE a citizen, just not
1159
- // ready for the reading rooms; the not-ready page offers the
1160
- // rank ladder + a way back to the hall. Bouncing to sign-in
1161
- // would loop, so send them to the friendly notice.
1162
- // - Thetes+ → next()
1163
- // Defense-in-depth: this is the curl-proof / no-JS-proof gate. The link-hiding
1164
- // in builders.js is only cosmetic.
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);
1454
+ }
1455
+
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+).
1165
1460
  async function requireNonXenosPage(req, res, next) {
1166
- try {
1167
- const session = await resolveSession(req);
1168
- if (!session) {
1169
- res.redirect(302, signInWithReturn(req));
1170
- return;
1171
- }
1172
- if (session.rank === 'xenos') {
1173
- res.redirect(302, '/builders/not-ready');
1174
- return;
1175
- }
1176
- next();
1177
- } catch (err) {
1178
- // Fail CLOSED — a lookup error sends the visitor to sign-in rather than
1179
- // leaking the gated page shell.
1180
- console.error('[gds] requireNonXenosPage lookup failed', err);
1181
- res.redirect(302, signInWithReturn(req));
1182
- }
1461
+ return runPageGate('requireNonXenosPage', req, res, next);
1183
1462
  }
1184
1463
 
1185
1464
  // ---------------------------------------------------------------------------
@@ -1277,6 +1556,63 @@ function requireRank(...allowed) {
1277
1556
  };
1278
1557
  }
1279
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
+
1280
1616
  // requireBfgPrincipal — the identity gate for the cross-builder memory WRITE
1281
1617
  // path (ADR 0026 §6C / task #446). Runs AFTER requireBuilder. This is
1282
1618
  // DELIBERATELY NOT a rank gate: the trust-boundary exception is bound to ONE
@@ -1320,6 +1656,7 @@ module.exports = {
1320
1656
  idpConfigured,
1321
1657
  openEnrollmentEnabled,
1322
1658
  firstTimeAdmissionBlocked,
1659
+ webAdmissionStatus,
1323
1660
  firstAdminLogin,
1324
1661
  maybeSeatFirstAdmin,
1325
1662
  startDeviceFlow,
@@ -1332,17 +1669,25 @@ module.exports = {
1332
1669
  completeIdpFlow,
1333
1670
  loadHubPublicKey,
1334
1671
  verifyHubAssertion,
1672
+ verifyHubLogoutToken,
1673
+ logoutTokenJtiSeenAndRecord,
1335
1674
  completeDeviceExchange,
1336
1675
  membershipRole,
1337
1676
  extractTokens,
1338
1677
  resolveSession,
1339
1678
  allowBoxScope,
1679
+ sessionScope,
1680
+ sessionScopeFor,
1681
+ sessionScopeAdmits,
1682
+ parseSessionScopes,
1683
+ SESSION_SCOPES,
1340
1684
  requireBuilder,
1341
1685
  requireBuilderPage,
1342
1686
  requireArchonPage,
1343
1687
  requireNonXenosPage,
1344
1688
  requireRank,
1345
1689
  rankMeetsThreshold,
1690
+ requirePermission,
1346
1691
  requireBfgPrincipal,
1347
1692
  requireDiscordBot,
1348
1693
  // Discord OAuth (account-linking — Phase 3 F2, ADR 0033)