@bongos/core 1.18.3 → 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 (469) hide show
  1. package/.bongos-core.json +1575 -285
  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/status-mirror-sync.js +114 -84
  347. package/src/bongos/api-errors.js +1 -0
  348. package/src/bongos/auth.js +447 -102
  349. package/src/bongos/db-kernel.js +27 -0
  350. package/src/bongos/db.js +39 -0
  351. package/src/bongos/module-scope-map.js +27 -1
  352. package/src/bongos/permission-path-check.js +136 -102
  353. package/src/bongos/repo-info.js +47 -11
  354. package/src/bongos/route-rank-check.js +429 -46
  355. package/src/bongos/routes/_helpers.js +12 -1
  356. package/src/bongos/routes/auth.js +219 -2
  357. package/src/bongos/routes/backup.js +58 -2
  358. package/src/bongos/routes/me.js +35 -1
  359. package/src/bongos/serve-internal.js +84 -15
  360. package/src/branding.js +32 -0
  361. package/src/instance-config.js +30 -0
  362. package/src/module-api.js +112 -1
  363. package/src/platform-server.js +86 -9
  364. package/tests/abandon_task.mjs +59 -3
  365. package/tests/api_cli_flags.mjs +53 -0
  366. package/tests/auth_page_gate.mjs +13 -0
  367. package/tests/backfill_sessions.mjs +127 -1
  368. package/tests/backup_cli.mjs +54 -0
  369. package/tests/bongos_box_connect.mjs +77 -0
  370. package/tests/box_access.mjs +130 -1
  371. package/tests/box_credential.mjs +125 -0
  372. package/tests/box_host_keys.mjs +24 -0
  373. package/tests/box_infra_resolve.mjs +149 -0
  374. package/tests/box_scope_session.mjs +6 -2
  375. package/tests/box_sync.mjs +55 -0
  376. package/tests/box_task_scope.mjs +99 -0
  377. package/tests/branding.mjs +12 -0
  378. package/tests/cf_ruleset_preflight.mjs +142 -0
  379. package/tests/cf_tunnel.mjs +37 -0
  380. package/tests/claim_gate_rebase.mjs +98 -0
  381. package/tests/clawback_confirmed_backlog.mjs +103 -0
  382. package/tests/cli_lib_formatters.mjs +20 -0
  383. package/tests/confirm_attribution.mjs +30 -46
  384. package/tests/credit_grant.mjs +198 -10
  385. package/tests/criterion_authoring_gate.mjs +272 -0
  386. package/tests/criterion_progress.mjs +33 -1
  387. package/tests/dev_lib.mjs +253 -0
  388. package/tests/devbox_artifact_gate.mjs +75 -0
  389. package/tests/devbox_dmg_backdrop.mjs +137 -0
  390. package/tests/devbox_icons.mjs +304 -0
  391. package/tests/devbox_installer_brand.mjs +232 -0
  392. package/tests/devbox_key_register.mjs +234 -0
  393. package/tests/docs_openapi_route.mjs +110 -0
  394. package/tests/doctor_hooks.mjs +141 -0
  395. package/tests/extract_json.mjs +140 -0
  396. package/tests/fitness.mjs +89 -0
  397. package/tests/gen_freshness.mjs +96 -0
  398. package/tests/goal_advisory.mjs +251 -0
  399. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  400. package/tests/goal_routes.mjs +59 -4
  401. package/tests/goal_tier.mjs +12 -3
  402. package/tests/governance_catalog.mjs +129 -0
  403. package/tests/governance_claim_eligibility.mjs +262 -0
  404. package/tests/governance_config.mjs +187 -0
  405. package/tests/governance_ownership_scope.mjs +410 -0
  406. package/tests/governance_page_gates.mjs +324 -0
  407. package/tests/governance_parity.mjs +336 -0
  408. package/tests/governance_principals.mjs +232 -0
  409. package/tests/governance_protected_surfaces.mjs +208 -0
  410. package/tests/governance_require_permission.mjs +223 -0
  411. package/tests/governance_resolver.mjs +84 -0
  412. package/tests/governance_role_sync.mjs +231 -0
  413. package/tests/governance_seed.mjs +127 -0
  414. package/tests/governance_session_scope.mjs +434 -0
  415. package/tests/governance_vanilla_boot.mjs +203 -0
  416. package/tests/grade_attribution.mjs +7 -7
  417. package/tests/hall_audit.mjs +223 -0
  418. package/tests/hall_nav.mjs +191 -0
  419. package/tests/hall_palette.mjs +206 -0
  420. package/tests/hall_signout.mjs +124 -0
  421. package/tests/hook_user_agents.mjs +10 -0
  422. package/tests/idea_triage.mjs +162 -0
  423. package/tests/idp_device_exchange.mjs +93 -0
  424. package/tests/init.mjs +51 -0
  425. package/tests/instance_config.mjs +65 -0
  426. package/tests/instance_mismatch_guard.mjs +23 -6
  427. package/tests/instance_release_config.mjs +104 -0
  428. package/tests/linkify_refs.mjs +15 -2
  429. package/tests/module_contributions.mjs +55 -0
  430. package/tests/module_loader.mjs +10 -5
  431. package/tests/module_scope_active_claims.mjs +94 -0
  432. package/tests/needs_rebase.mjs +92 -0
  433. package/tests/one_active_claim_per_session.mjs +76 -0
  434. package/tests/override_attribution.mjs +10 -8
  435. package/tests/override_request_ownership.mjs +8 -4
  436. package/tests/parse_cookie_decode.mjs +42 -0
  437. package/tests/pay_on_land.mjs +129 -0
  438. package/tests/platform_boot.mjs +100 -1
  439. package/tests/post_login_landing.mjs +42 -0
  440. package/tests/preflight_checks.mjs +72 -0
  441. package/tests/provision.mjs +132 -7
  442. package/tests/publish_branch_route.mjs +53 -0
  443. package/tests/publish_reconciler.mjs +187 -2
  444. package/tests/repo_info_binding.mjs +248 -0
  445. package/tests/reward_gate.mjs +11 -2
  446. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  447. package/tests/reward_mode_surfacing.mjs +168 -0
  448. package/tests/route_permission_check.mjs +301 -0
  449. package/tests/route_rank_check.mjs +254 -12
  450. package/tests/route_validation.mjs +30 -0
  451. package/tests/sequence.mjs +267 -0
  452. package/tests/session_digest.mjs +44 -0
  453. package/tests/session_index.mjs +18 -6
  454. package/tests/session_records.mjs +39 -1
  455. package/tests/session_reward_on_land.mjs +381 -0
  456. package/tests/session_reward_reconcile.mjs +229 -0
  457. package/tests/ship_api_client_pathspec.mjs +100 -0
  458. package/tests/ship_base_flag.mjs +93 -0
  459. package/tests/ship_ci_deploy.mjs +80 -3
  460. package/tests/ship_upstream_deletion.mjs +57 -0
  461. package/tests/smoke_target.mjs +233 -0
  462. package/tests/start_env_guard.mjs +87 -0
  463. package/tests/start_rebase_warning.mjs +103 -0
  464. package/tests/status_mirror_sync.mjs +122 -0
  465. package/tests/stop_refcheck.mjs +19 -1
  466. package/tests/task_goal_link.mjs +15 -6
  467. package/tests/upgrade.mjs +139 -0
  468. package/tests/web_admission_status.mjs +162 -0
  469. package/tests/write_validation_gaps.mjs +110 -0
@@ -10,10 +10,12 @@
10
10
  // must classify to a KNOWN rank — instead of diffing against a frozen snapshot
11
11
  // (the old canonical-routes.json approach, which cried false drift whenever the
12
12
  // route set legitimately grew). Classification is STRUCTURAL: a route is gated
13
- // when its middleware chain carries requireRank/requireBuilder, OR it bears an
14
- // explicit `rank:` annotation. So an intentionally-public route is fine — it
15
- // just has to SAY so. A route the parser cannot classify ('unknown') means the
16
- // system can't verify it's protected, which is a finding:
13
+ // when its middleware chain carries requireRank/requireBuilder/requirePermission.
14
+ // A `rank:` annotation is NOT a gate — it is how an intentionally-PUBLIC route
15
+ // SAYS so (`rank: public`). A comment claiming a privileged rank over a route with
16
+ // no middleware certifies nothing, and is a finding of its own (BV1.R109d). A route
17
+ // the parser cannot classify ('unknown') means the system can't verify it's
18
+ // protected, which is likewise a finding:
17
19
  // - write method (POST/PUT/PATCH/DELETE) → 'blocker' → hard FAIL on its own.
18
20
  // - read method (GET) → 'major' → 'concerns'; the Hacker adjudicates intent.
19
21
  //
@@ -42,6 +44,128 @@ const KNOWN_RANKS = new Set(['public', 'any-builder', 'metic+archon', 'archon',
42
44
  const WRITE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
43
45
  const SPECIALTY = 'deterministic: every src/bongos/routes/ route must classify to a known rank (ADR 0016)';
44
46
 
47
+ // ── permission gates (BV1.R97/R109, ADR 0151) ────────────────────────────────
48
+ //
49
+ // A migrated route gates on PERMISSION ATOMS — auth.requirePermission('backup.manage')
50
+ // — instead of a rank. `requirePermission` must mount AFTER `requireBuilder`, so before
51
+ // this arm existed a migrated route fell through to the requireBuilder branch and
52
+ // classified 'any-builder': a downgrade that reads as legitimate. To classify it
53
+ // honestly the auditor needs each atom's rank FLOOR (the rank that holds it under
54
+ // governance's compat seed), which is declared exactly once — in
55
+ // modules/governance/catalog.js. There is deliberately NO second permission→rank table
56
+ // here; a second list is a list that drifts.
57
+ //
58
+ // READ AS DATA, NOT require()d. ADR 0083's one-way rule forbids core from importing a
59
+ // MODULE's code (scripts/gds/fitness.js enforces it on require edges) and this file is
60
+ // core, so we parse the catalog's `floor:` declarations out of its SOURCE — the same
61
+ // technique this file already applies to modules/*/routes/*.js, and the posture the
62
+ // sibling trust primitive permission-path-check.js takes with
63
+ // modules/governance/protected-surfaces.json. Like those, it runs with no DB, no
64
+ // server and no module loader, so it works inside a git hook.
65
+ //
66
+ // LAZY + FAIL-CLOSED. Loaded on first use, so an instance whose routes carry no
67
+ // permission gate and no permission pin never reads it. An unreadable or empty catalog
68
+ // THROWS rather than degrading to "every atom is unknown"; an atom the catalog does not
69
+ // define classifies 'unknown', which hard-fails a write route.
70
+ const CATALOG_PATH = path.join(MODULES_DIR, 'governance', 'catalog.js');
71
+ // low → high authority. Mirrors catalog.js RANK_ORDER; the parse below rejects any
72
+ // floor outside it, so a divergence surfaces as 'unknown' (loud) rather than as trust.
73
+ const PERMISSION_RANK_ORDER = ['xenos', 'thetes', 'metic', 'archon'];
74
+ // A permission floor → the rank LABEL this auditor speaks. The vocabulary has no
75
+ // 'thetes' tier (requireRank('xenos') has always collapsed to 'any-builder' too), so
76
+ // both sub-Metic floors read 'any-builder': the label may UNDER-report strictness,
77
+ // never over-report it.
78
+ const FLOOR_LABEL = { xenos: 'any-builder', thetes: 'any-builder', metic: 'metic+archon', archon: 'archon' };
79
+ // How strict each label is, for combining a permission gate with a rank gate that sits
80
+ // on the same route (they AND, so the stricter one is the route's real floor).
81
+ const LABEL_STRICTNESS = { public: 0, 'any-builder': 1, 'metic+archon': 2, archon: 3, 'bfg-principal': 4 };
82
+ // A pin value written in permission terms: 'perm:<key>[,<key>…]' (see EXPECTED_RANKS).
83
+ const PERM_PIN_PREFIX = 'perm:';
84
+
85
+ // key → floor ('xenos'|'thetes'|'metic'|'archon'|null) from the catalog SOURCE. One
86
+ // catalog entry per line is the shape catalog.js has and its own load-time invariants
87
+ // keep; an entry this misses simply never resolves, which fails closed.
88
+ function parsePermissionFloors(text) {
89
+ const floors = new Map();
90
+ for (const line of String(text).split('\n')) {
91
+ const k = /\bkey:\s*'([^']+)'/.exec(line);
92
+ if (!k) continue;
93
+ const f = /\bfloor:\s*(?:'([a-z]+)'|null)/.exec(line);
94
+ if (!f) continue;
95
+ floors.set(k[1], f[1] || null); // f[1] undefined ⇒ floor: null ⇒ a machine principal
96
+ }
97
+ return floors;
98
+ }
99
+
100
+ let _floorsCache = null;
101
+ function permissionFloors(catalogPath = CATALOG_PATH) {
102
+ const cacheable = catalogPath === CATALOG_PATH; // an explicit path (tests) is never cached
103
+ if (cacheable && _floorsCache) return _floorsCache;
104
+ let text;
105
+ try {
106
+ text = fs.readFileSync(catalogPath, 'utf8');
107
+ } catch (e) {
108
+ throw new Error(`permission catalog unreadable at ${catalogPath}: ${e.message}. `
109
+ + 'Refusing to classify permission gates with no floor data (ADR 0151).');
110
+ }
111
+ const floors = parsePermissionFloors(text);
112
+ if (floors.size === 0) {
113
+ throw new Error(`permission catalog ${catalogPath} declares no permissions. `
114
+ + 'Refusing to classify permission gates against an empty catalog (ADR 0151).');
115
+ }
116
+ if (cacheable) _floorsCache = floors;
117
+ return floors;
118
+ }
119
+
120
+ // The effective rank label of a requirePermission(...) gate, or 'unknown'.
121
+ //
122
+ // AND semantics (auth.requirePermission holds EVERY named atom), so the gate's floor is
123
+ // the STRICTEST floor among them. Fail-closed to 'unknown' — never to a rank — for:
124
+ // • no atoms named (a mis-authored gate; requirePermission() 403s everyone anyway)
125
+ // • an atom the catalog does not define (typo, renamed, deleted): it grants nothing,
126
+ // so treating it as "gated" would be the exact silent hole this arm exists to close
127
+ // • a PRINCIPAL atom (floor null — held by no rank-role, only by an exact
128
+ // builders.system_role). A route wanting one must use requireBfgPrincipal.
129
+ function rankForPermissions(keys, floors = permissionFloors()) {
130
+ if (!Array.isArray(keys) || keys.length === 0) return 'unknown';
131
+ let strictest = -1;
132
+ for (const key of keys) {
133
+ if (!floors.has(key)) return 'unknown';
134
+ const idx = PERMISSION_RANK_ORDER.indexOf(floors.get(key)); // null/unknown floor → -1
135
+ if (idx === -1) return 'unknown';
136
+ if (idx > strictest) strictest = idx;
137
+ }
138
+ return FLOOR_LABEL[PERMISSION_RANK_ORDER[strictest]];
139
+ }
140
+
141
+ // ── how a guard is REFERENCED in a route's argument list (BV1.R109d) ─────────
142
+ //
143
+ // Core routes call the guards through a namespace (`auth.requireBuilder`); a module
144
+ // reaches the same guards through the published doorway, and may either namespace them
145
+ // (`api.requireBuilder`) or DESTRUCTURE them — `const { requireBuilder } = api` — and
146
+ // pass BARE identifiers. Recognising only the namespaced spelling made 11 correctly
147
+ // gated module routes (modules/platform-identity/routes/*) invisible to the middleware
148
+ // arm; they survived only because their `rank:` comment happened to tell the truth.
149
+ //
150
+ // The bare form is honoured ONLY in ARGUMENT POSITION (directly after `(` or `,`), so
151
+ // the identifier appearing in prose — or inside a `function` handler's body, which the
152
+ // `=> {` truncation below does not cut away — can never be mistaken for a gate.
153
+ const GATE_REF = String.raw`(?:(?:auth|api)\.|[(,]\s*)`;
154
+ const RE_RANK_XENOS = new RegExp(`${GATE_REF}requireRank\\(\\s*['"]xenos['"]`);
155
+ const RE_RANK_METIC_ARCHON = new RegExp(`${GATE_REF}requireRank\\(\\s*['"]metic['"]\\s*,\\s*['"]archon['"]\\s*\\)`);
156
+ const RE_RANK_ARCHON = new RegExp(`${GATE_REF}requireRank\\(\\s*['"]archon['"]\\s*\\)`);
157
+ const RE_BFG_PRINCIPAL = new RegExp(`${GATE_REF}requireBfgPrincipal`);
158
+ const RE_REQUIRE_BUILDER = new RegExp(`${GATE_REF}requireBuilder`);
159
+ const RE_REQUIRE_PERMISSION = new RegExp(`${GATE_REF}requirePermission\\(([^)]*)\\)`);
160
+
161
+ // The permission atoms a route's middleware list names, or null when it carries no
162
+ // permission gate. `[]` (an empty gate) is DISTINCT from null and resolves 'unknown'.
163
+ function permissionKeysIn(tail) {
164
+ const m = RE_REQUIRE_PERMISSION.exec(tail);
165
+ if (!m) return null;
166
+ return [...m[1].matchAll(/['"]([^'"]+)['"]/g)].map((x) => x[1]);
167
+ }
168
+
45
169
  // Expected-rank allowlist for KNOWN privileged routes (#999, red-team residual).
46
170
  // The base invariant above only checks a route classifies to SOME known rank —
47
171
  // so an accidental DOWNGRADE (an Archon-only route silently dropped to
@@ -59,16 +183,23 @@ const SPECIALTY = 'deterministic: every src/bongos/routes/ route must classify t
59
183
  // downgrade-protection; routes not listed still get the base "must classify"
60
184
  // check. A key present here that no longer exists in the route set is itself a
61
185
  // finding (a privileged route was removed/renamed without updating this pin).
186
+ //
187
+ // A PIN VALUE IS EITHER A RANK LABEL OR AN AUTHORITY ATOM ('perm:<key>[,<key>]' —
188
+ // BV1.R109, ADR 0151). Both forms are satisfied by BOTH gate spellings while
189
+ // R104-R107 migrate `requireRank` call sites to `requirePermission`, so migrating a
190
+ // pinned route no longer forces an edit here. Prefer the permission spelling for the
191
+ // pins whose downgrade is a true authority breach: once governance grows CUSTOM roles
192
+ // the ATOM, not the rank, is the authority, and a rank pin stops describing it.
62
193
  const EXPECTED_RANKS = {
63
194
  // builder authority — rank + lifecycle status mutation
64
- 'PATCH /builders/:id/rank': 'archon',
65
- 'PATCH /builders/:id/status': 'archon',
195
+ 'PATCH /builders/:id/rank': 'perm:builder.rank.set',
196
+ 'PATCH /builders/:id/status': 'perm:builder.status.set',
66
197
  // session revocation (kill another builder's / all sessions)
67
- 'POST /auth/revoke': 'archon',
68
- 'POST /auth/revoke-all': 'archon',
198
+ 'POST /auth/revoke': 'perm:auth.session.revoke',
199
+ 'POST /auth/revoke-all': 'perm:auth.session.revoke',
69
200
  // forensic surfaces
70
- 'GET /audit-log': 'archon',
71
- 'GET /builders/roster': 'archon',
201
+ 'GET /audit-log': 'perm:audit_log.read',
202
+ 'GET /builders/roster': 'perm:builder.roster.read',
72
203
  // deliberately any-builder (task 1877) — the invite-picker roster (Goals
73
204
  // invite control, task 1762) needs every active builder's identity, not just
74
205
  // /leaderboard's top-100-by-credits window. Pinned so an accidental
@@ -89,6 +220,10 @@ const EXPECTED_RANKS = {
89
220
  'GET /security/docs': 'archon',
90
221
  'GET /security/docs/:slug': 'archon',
91
222
  'POST /security/docs': 'archon',
223
+ // The full threat model + defenses inventory (ADR 0020). Same class as the docs
224
+ // pins above — a silent downgrade puts the defense inventory below the trust line.
225
+ // Newly VISIBLE in BV1.R109b (declared multi-line, so previously unpinnable).
226
+ 'GET /security/adr': 'metic+archon',
92
227
  };
93
228
 
94
229
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
@@ -100,6 +235,17 @@ const EXPECTED_RANKS = {
100
235
  // box-fleet routes moved here from EXPECTED_RANKS in BV1.R43 / task 1412 — the pins
101
236
  // follow the routes; the intent is unchanged: closing/blocking another builder's
102
237
  // paid infra, and reading the fleet/cost roster, stay archon-only.)
238
+ //
239
+ // The `ideas` / `memory` / `security` blocks were added in BV1.R109b: their routes are
240
+ // declared multi-line and so were invisible to this auditor until it learned that form
241
+ // — meaning they could never have been pinned. They are pinned by RANK, not by
242
+ // `perm:` atom, deliberately: BV1.R104-R107 are migrating these very files to
243
+ // requirePermission right now, and a rank pin is satisfied by EITHER spelling (a
244
+ // permission gate resolves through the catalog into the same vocabulary), whereas an
245
+ // atom pin would red the build if the migration picks a different atom than guessed.
246
+ // The catalog already declares the atoms each will gate on — `security.report.file`
247
+ // (metic), `security.report.adjudicate` / `idea.ratify` / `memory.read.any` (archon) —
248
+ // so tightening these to `perm:` pins is a clean follow-up once R104-R107 have landed.
103
249
  const MODULE_EXPECTED_RANKS = {
104
250
  'dev-box': {
105
251
  'GET /boxes': 'archon',
@@ -112,10 +258,11 @@ const MODULE_EXPECTED_RANKS = {
112
258
  lifecycle: {
113
259
  // task authoring / promotion / abandonment. Create + promote re-ranked
114
260
  // Archon→Metic+ (ADR 0090): a Metic who can triage ideas may also seed + shape
115
- // tasks. Abandon (terminal scope removal) stays Archon.
261
+ // tasks. Abandon followed (ADR 0152) — a Metic who may author work may retire
262
+ // it; /promote carries the abandoned→backlog undo at the same rank.
116
263
  'POST /tasks': 'metic+archon',
117
264
  'POST /tasks/:id/promote': 'metic+archon',
118
- 'POST /tasks/:id/abandon': 'archon',
265
+ 'POST /tasks/:id/abandon': 'metic+archon',
119
266
  // override-request adjudication (lands credit + ship attribution).
120
267
  'GET /override-requests': 'archon',
121
268
  'POST /override-requests/:id/decide': 'archon',
@@ -185,25 +332,85 @@ const MODULE_EXPECTED_RANKS = {
185
332
  'POST /goals/:id/requests/:reqId/withdraw': 'any-builder',
186
333
  // criterion confirmation + goal auto-achieve (ADR 0086 §6) — the planner's close loop.
187
334
  'POST /done-when/:criterionId/satisfy': 'metic+archon',
188
- // runtime criterion create (ADR 0105 gap fix, task 1874) — scope-truth write, Archon-only.
189
- 'POST /versions/:id/done-when': 'archon',
335
+ // runtime criterion create (ADR 0105 gap fix, task 1874) — re-ranked Archon→Metic+
336
+ // (ADR 0154) as the OPEN of the loop `satisfy` above closes. Not a bare widening:
337
+ // a non-Archon must name a goal they own or manage (authorizeCriterionCreate) on a
338
+ // planning/building version, so the in-handler wall is load-bearing — and an
339
+ // unauthorized caller is masked as bad_goal_id. Unsatisfy stays Archon.
340
+ 'POST /versions/:id/done-when': 'metic+archon',
341
+ // ...and its inverse stays ARCHON. Un-flipping a CONFIRMED criterion rewrites
342
+ // shipped scope-truth; ADR 0154's whole safety argument is that the backward
343
+ // ratchet did not move with the create. Previously unpinned — pinned here so a
344
+ // silent downgrade reds the build (task 1002538).
345
+ 'POST /done-when/:criterionId/unsatisfy': 'archon',
190
346
  // gate-surface PR approval from the hall (task 1179, ADR 0058 amendment) — the
191
347
  // approve route clears the gate-review hard floor; the list route exposes gate internals.
192
348
  'GET /gate-approvals': 'archon',
193
349
  'POST /gate-approvals/:pr/approve': 'archon',
194
350
  },
351
+ // Ratifying a criterion proposal WRITES scope-truth (it creates a done-when
352
+ // criterion). Metic+ may file the proposal; only the Archon may ratify it.
353
+ ideas: {
354
+ 'POST /inbox/:id/ratify': 'archon',
355
+ },
356
+ // Cross-builder memory READS — the counterpart to the BFG cross-builder WRITE the
357
+ // KNOWN_RANKS note above already protects. A downgrade here breaks the memory
358
+ // isolation invariant: any builder could read every other builder's memory.
359
+ memory: {
360
+ 'GET /memory/builders/:builderId/files': 'archon',
361
+ 'GET /memory/builders/:builderId/file': 'archon',
362
+ },
363
+ // Vulnerability-report intake + adjudication. The four adjudication routes move a
364
+ // PAYOUT (confirm awards drachmae, dispute clears it), and the list route exposes
365
+ // every builder's exploit detail — the same "attacker cookbook" argument as the
366
+ // /security/docs pins. Intake is pinned 'metic+archon' rather than archon because it
367
+ // is DELIBERATELY Metic+ (the rank gate is the bounty noise-floor, V3.R63): the pin
368
+ // catches an accidental widening to Xenos as loudly as it catches a gate removal.
369
+ security: {
370
+ 'POST /security/reports': 'metic+archon',
371
+ 'GET /security/reports': 'archon',
372
+ 'POST /security/reports/:id/confirm': 'archon',
373
+ 'POST /security/reports/:id/dispute': 'archon',
374
+ 'POST /security/reports/:id/fix': 'archon',
375
+ 'POST /security/reports/:id/link-fix': 'archon',
376
+ },
195
377
  };
196
378
 
197
- // Classify one route's rank from the source lines and the declaration index.
379
+ // The rank a route's RANK middleware implies, or null when it carries none.
380
+ // Every spelling GATE_REF above accepts counts: namespaced (`auth.`/`api.`) or the
381
+ // bare identifier a module gets by destructuring the doorway.
382
+ function rankFromRankGate(tail) {
383
+ if (RE_RANK_XENOS.test(tail)) return 'any-builder';
384
+ if (RE_RANK_METIC_ARCHON.test(tail)) return 'metic+archon';
385
+ if (RE_RANK_ARCHON.test(tail)) return 'archon';
386
+ // Matched BEFORE the requireBuilder branch in THIS classifier's if-chain (not
387
+ // a statement about middleware execution order — the actual route chain runs
388
+ // requireBuilder → requireBfgPrincipal). The BFG write route carries both;
389
+ // requireBuilder authenticates, requireBfgPrincipal narrows to the one service
390
+ // principal, and the principal gate is the load-bearing one we want recorded.
391
+ if (RE_BFG_PRINCIPAL.test(tail)) return 'bfg-principal';
392
+ if (RE_REQUIRE_BUILDER.test(tail)) return 'any-builder';
393
+ return null;
394
+ }
395
+
396
+ // Classify one route's gate from the source lines and the declaration index.
397
+ // Returns { rank, perms, via } — `perms` is the permission atoms the route names, or
398
+ // null when it carries no permission gate; `via` is the PROVENANCE of the label:
399
+ // 'middleware' (a real gate), 'annotation' (only a `rank:` comment), or null (neither).
198
400
  // 1. The middleware chain on the declaration line + next 20 lines (captures
199
- // multi-line declarations) — requireRank/requireBuilder is authoritative.
401
+ // multi-line declarations) — requirePermission/requireRank/requireBuilder is
402
+ // authoritative.
200
403
  // 2. Otherwise a `rank:` annotation anywhere in the CONTIGUOUS comment block
201
404
  // immediately above the declaration. Scanning the whole block (not a fixed
202
405
  // 5-line window) is the fix for routes like GET /public/repo-info, whose
203
406
  // annotation sits atop a 6-line comment — just out of the old window.
204
407
  // 3. Neither → 'unknown'. An annotation whose value isn't a known rank (typo)
205
408
  // is likewise 'unknown' — that surfaces the typo rather than trusting it.
206
- function classifyRank(lines, i) {
409
+ //
410
+ // `via` is what keeps a COMMENT from passing as a trust decision (BV1.R109d): callers
411
+ // treat an 'annotation' label as a claim, not a verified gate. Only `rank: public` is
412
+ // a claim a comment can honestly make on its own — asserting nothing is protected.
413
+ function classifyGate(lines, i, { floors } = {}) {
207
414
  const window = lines.slice(i, i + 20).join('\n');
208
415
  // Only the route's own MIDDLEWARE LIST decides its rank — not its handler body.
209
416
  // The middleware are the args before the handler function; the handler opens
@@ -216,21 +423,22 @@ function classifyRank(lines, i) {
216
423
  // (e.g. a `function` handler), fall back to the full window — prior behaviour.
217
424
  const handlerAt = window.search(/=>\s*\{/);
218
425
  const tail = handlerAt === -1 ? window : window.slice(0, handlerAt);
219
- // Accept BOTH prefixes: core routes gate via `auth.requireRank(...)`; a module
220
- // route reaches the SAME guards through the published doorway as `api.requireRank(...)`
221
- // (src/module-api re-exports requireRank/requireBuilder/requireBfgPrincipal). Without
222
- // the `api.` arm a correctly-gated module write route would mis-classify as 'unknown'
223
- // → a false blocker, and the moved dev-box routes (BV1.R43/M07) would never pass.
224
- if (/(?:auth|api)\.requireRank\(\s*['"]xenos['"]/.test(tail)) return 'any-builder';
225
- if (/(?:auth|api)\.requireRank\(\s*['"]metic['"]\s*,\s*['"]archon['"]\s*\)/.test(tail)) return 'metic+archon';
226
- if (/(?:auth|api)\.requireRank\(\s*['"]archon['"]\s*\)/.test(tail)) return 'archon';
227
- // Matched BEFORE the requireBuilder branch in THIS classifier's if-chain (not
228
- // a statement about middleware execution order — the actual route chain runs
229
- // requireBuilder → requireBfgPrincipal). The BFG write route carries both;
230
- // requireBuilder authenticates, requireBfgPrincipal narrows to the one service
231
- // principal, and the principal gate is the load-bearing one we want recorded.
232
- if (/(?:auth|api)\.requireBfgPrincipal/.test(tail)) return 'bfg-principal';
233
- if (/(?:auth|api)\.requireBuilder/.test(tail)) return 'any-builder';
426
+
427
+ const perms = permissionKeysIn(tail);
428
+ const byRank = rankFromRankGate(tail);
429
+ if (perms) {
430
+ const byPermission = rankForPermissions(perms, floors || permissionFloors());
431
+ // An unresolvable permission gate is 'unknown' EVEN THOUGH a requireBuilder sits
432
+ // beside it (every permission gate mounts after one). Deferring to that
433
+ // requireBuilder would let a typo'd atom — which grants nothing — read as
434
+ // 'any-builder', i.e. as gated. Fail closed and loud instead.
435
+ if (byPermission === 'unknown') return { rank: 'unknown', perms, via: 'middleware' };
436
+ // The two gate forms AND when a route carries both, so the stricter one is the
437
+ // route's real floor. Reporting the looser one would understate authority.
438
+ const stricter = byRank && LABEL_STRICTNESS[byRank] > LABEL_STRICTNESS[byPermission] ? byRank : byPermission;
439
+ return { rank: stricter, perms, via: 'middleware' };
440
+ }
441
+ if (byRank) return { rank: byRank, perms: null, via: 'middleware' };
234
442
 
235
443
  for (let j = i - 1; j >= 0; j--) {
236
444
  const line = lines[j];
@@ -243,24 +451,95 @@ function classifyRank(lines, i) {
243
451
  const c = /\brank:\s*([a-z]+(?:[+-][a-z]+)*)/i.exec(line);
244
452
  if (c) {
245
453
  const v = c[1].toLowerCase();
246
- return KNOWN_RANKS.has(v) ? v : 'unknown';
454
+ // A typo is 'unknown' with NO provenance — there is nothing to trust here.
455
+ return KNOWN_RANKS.has(v)
456
+ ? { rank: v, perms: null, via: 'annotation' }
457
+ : { rank: 'unknown', perms: null, via: null };
247
458
  }
248
459
  }
249
460
  }
250
- return 'unknown';
461
+ return { rank: 'unknown', perms: null, via: null };
462
+ }
463
+
464
+ // Back-compat scalar form (gen-api-docs + several tests call this directly).
465
+ function classifyRank(lines, i, opts) {
466
+ return classifyGate(lines, i, opts).rank;
251
467
  }
252
468
 
253
- // Parse route declarations + ranks from a route file's source text. Pure
254
- // (no fs) so it's trivially testable. Returns [{ method, route, key, rank }].
255
- function classifyText(text) {
469
+ // ── route-declaration scanning (BV1.R109b) ───────────────────────────────────
470
+ //
471
+ // A route declaration OPENS with `router.<method>(`. The path may sit on that same
472
+ // line, or — for a long middleware chain — on the line below it:
473
+ //
474
+ // router.get(
475
+ // '/security/adr',
476
+ // auth.requireBuilder,
477
+ // auth.requireRank('metic', 'archon'),
478
+ //
479
+ // Until BV1.R109b the scanner required method AND quoted path on ONE line, so a
480
+ // multi-line declaration was never emitted: 10 real routes (6 of them writes,
481
+ // 2 reading other builders' memory) were INVISIBLE to this auditor — never checked
482
+ // for being ungated or downgraded, never pinned, never published by gen-api-docs.
483
+ //
484
+ // ROUTE_DECL_RE is therefore the CENSUS: every line it matches must resolve to
485
+ // exactly one classified route. A declaration whose path cannot be resolved
486
+ // statically is reported by checkRouteRanks (below) instead of being skipped —
487
+ // fail-closed, because a route the auditor cannot even NAME is strictly less
488
+ // verifiable than one whose gate it merely cannot classify.
489
+ const ROUTE_DECL_RE = /^\s*router\.(get|post|patch|put|delete)\s*\(/;
490
+ const ROUTE_DECL_SAME_LINE_RE = /^\s*router\.(get|post|patch|put|delete)\s*\(\s*['"]([^'"]+)['"]/;
491
+ // A continuation line carrying the quoted path, then the comma before the middleware.
492
+ const ROUTE_PATH_LINE_RE = /^\s*['"]([^'"]+)['"]\s*,/;
493
+ // How far past a multi-line opener to look for the path. It is on the very next line
494
+ // in every real case; the slack only tolerates an interleaved comment.
495
+ const PATH_LOOKAHEAD = 5;
496
+
497
+ // The literal path a declaration at line `i` names, or null when it is not statically
498
+ // resolvable (a template literal, a variable, a computed path).
499
+ function routePathAt(lines, i) {
500
+ const same = ROUTE_DECL_SAME_LINE_RE.exec(lines[i]);
501
+ if (same) return same[2];
502
+ // Multi-line form: nothing but whitespace/comment may follow the '(' …
503
+ const rest = lines[i].slice(lines[i].indexOf('(') + 1).trim();
504
+ if (rest !== '' && !rest.startsWith('//')) return null;
505
+ // … and the first content line below must open with the quoted path.
506
+ for (let j = i + 1; j < lines.length && j <= i + PATH_LOOKAHEAD; j++) {
507
+ const t = lines[j].trim();
508
+ if (t === '' || t.startsWith('//')) continue;
509
+ const m = ROUTE_PATH_LINE_RE.exec(lines[j]);
510
+ return m ? m[1] : null;
511
+ }
512
+ return null;
513
+ }
514
+
515
+ // Every `router.<method>(` declaration in a route file's source, in source order:
516
+ // [{ method, route, line }] — `route` is null when the path did not resolve. This is
517
+ // the single scanner BOTH consumers use (this auditor and gen-api-docs.js), so the
518
+ // published API contract and the enforced gate list can never enumerate different
519
+ // routes. There is deliberately no second declaration regex in gen-api-docs.
520
+ function scanDeclarations(text) {
256
521
  const lines = String(text).split('\n');
257
522
  const out = [];
258
523
  for (let i = 0; i < lines.length; i++) {
259
- const m = /^\s*router\.(get|post|patch|put|delete)\s*\(\s*['"]([^'"]+)['"]/.exec(lines[i]);
524
+ const m = ROUTE_DECL_RE.exec(lines[i]);
260
525
  if (!m) continue;
261
- const method = m[1].toUpperCase();
262
- const route = m[2];
263
- out.push({ method, route, key: `${method} ${route}`, rank: classifyRank(lines, i) });
526
+ out.push({ method: m[1].toUpperCase(), route: routePathAt(lines, i), line: i });
527
+ }
528
+ return out;
529
+ }
530
+
531
+ // Parse route declarations + gates from a route file's source text. Pure (no fs
532
+ // beyond the lazily-loaded permission catalog) so it's trivially testable.
533
+ // Returns [{ method, route, key, rank, perms }].
534
+ function classifyText(text, opts) {
535
+ const lines = String(text).split('\n');
536
+ const out = [];
537
+ for (const d of scanDeclarations(text)) {
538
+ if (d.route === null) continue; // surfaced as a finding by checkRouteRanks, not dropped
539
+ // classifyGate scans FROM the declaration line, so for a multi-line declaration the
540
+ // middleware (which sits below the path line) is inside its 20-line window, and its
541
+ // comment-block `rank:` scan still starts directly above the declaration.
542
+ out.push({ method: d.method, route: d.route, key: `${d.method} ${d.route}`, ...classifyGate(lines, d.line, opts) });
264
543
  }
265
544
  return out;
266
545
  }
@@ -290,6 +569,54 @@ function discoverRouteFiles({ routesDir = ROUTES_DIR, modulesDir = MODULES_DIR }
290
569
  return out;
291
570
  }
292
571
 
572
+ // A pin value → { kind: 'rank', rank } | { kind: 'permission', perms[] }.
573
+ // 'perm:a,b' names the AUTHORITY ATOMS the route must gate on; anything else is a
574
+ // rank label (the pre-governance spelling, still valid).
575
+ function parsePin(value) {
576
+ if (typeof value === 'string' && value.startsWith(PERM_PIN_PREFIX)) {
577
+ return {
578
+ kind: 'permission',
579
+ perms: value.slice(PERM_PIN_PREFIX.length).split(',').map((s) => s.trim()).filter(Boolean),
580
+ };
581
+ }
582
+ return { kind: 'rank', rank: value };
583
+ }
584
+
585
+ // Why route `r` fails its pin, or null when it satisfies it.
586
+ //
587
+ // EITHER GATE FORM SATISFIES A PIN — that is what makes the R104-R107 migration
588
+ // window survivable. A rank pin is satisfied by whatever the route classifies to
589
+ // (a requirePermission gate resolves through the catalog into the same vocabulary).
590
+ // A permission pin is satisfied by the route naming exactly those atoms, OR — while
591
+ // the route is still un-migrated — by carrying the rank gate those atoms' floor
592
+ // resolves to. Neither direction lets a route drop below what the pin demands.
593
+ function pinMismatch(expected, r) {
594
+ const pin = parsePin(expected);
595
+ // BV1.R109d — a pin is NEVER satisfied by an annotation. Pins exist for routes
596
+ // "whose downgrade would be a real authority breach", so satisfying one with a
597
+ // `// rank: archon` comment over a route carrying no middleware would let the
598
+ // deterministic alarm certify prose. Checked before the label comparisons below
599
+ // because the label itself is what the comment fabricates.
600
+ if (r.via === 'annotation') {
601
+ return `is pinned to '${expected}' but carries NO auth middleware — its rank comes only from a \`rank:\` comment, which certifies nothing`;
602
+ }
603
+ if (pin.kind === 'rank') {
604
+ return r.rank === pin.rank ? null : `classifies as '${r.rank}' but is pinned to '${pin.rank}'`;
605
+ }
606
+ const pinnedRank = rankForPermissions(pin.perms);
607
+ if (pinnedRank === 'unknown') {
608
+ return `is pinned to permission(s) '${pin.perms.join(', ')}' the governance catalog does not define`;
609
+ }
610
+ if (r.perms) {
611
+ const got = [...r.perms].sort().join(',');
612
+ const want = [...pin.perms].sort().join(',');
613
+ return got === want ? null : `gates on permission(s) '${got}' but is pinned to '${want}'`;
614
+ }
615
+ return r.rank === pinnedRank
616
+ ? null
617
+ : `classifies as '${r.rank}' but is pinned to permission(s) '${pin.perms.join(', ')}' (rank floor '${pinnedRank}')`;
618
+ }
619
+
293
620
  // Walk every route file (core + modules) and assert the invariant. Returns:
294
621
  // { ok, hardFail, routeCount, routes[], findings[] }
295
622
  // findings[]: { severity, file, route, method, rank, summary }
@@ -299,13 +626,34 @@ function checkRouteRanks({ routesDir = ROUTES_DIR, modulesDir } = {}) {
299
626
  // caller (or a hermetic test) may pass modulesDir explicitly to override.
300
627
  if (modulesDir === undefined) modulesDir = path.resolve(routesDir, '..', '..', '..', 'modules');
301
628
  const routes = [];
629
+ const unresolved = [];
302
630
  for (const { abs, rel } of discoverRouteFiles({ routesDir, modulesDir })) {
303
- for (const r of classifyText(fs.readFileSync(abs, 'utf8'))) {
304
- routes.push({ ...r, file: rel });
631
+ const text = fs.readFileSync(abs, 'utf8');
632
+ for (const r of classifyText(text)) routes.push({ ...r, file: rel });
633
+ for (const d of scanDeclarations(text)) {
634
+ if (d.route === null) unresolved.push({ ...d, file: rel });
305
635
  }
306
636
  }
307
637
 
308
638
  const findings = [];
639
+
640
+ // BV1.R109b — a declaration whose path this scanner cannot resolve is INVISIBLE to
641
+ // every check below, which is exactly the hole that hid 10 multi-line routes. Report
642
+ // it at the same severity as an unclassifiable gate: a write is a hard fail, a read
643
+ // is a concern the Hacker adjudicates.
644
+ for (const d of unresolved) {
645
+ const isWrite = WRITE_METHODS.has(d.method);
646
+ findings.push({
647
+ severity: isWrite ? 'blocker' : 'major',
648
+ file: d.file,
649
+ route: `${d.method} (unresolved ${d.file}:${d.line + 1})`,
650
+ method: d.method,
651
+ rank: '(unparsed)',
652
+ summary: `A router.${d.method.toLowerCase()}( declaration at ${d.file}:${d.line + 1} names a path this auditor cannot resolve statically, so the route is invisible to the rank check AND absent from the generated API contract. `
653
+ + 'Declare the path as a literal string — on the declaration line, or on the line directly below it.',
654
+ });
655
+ }
656
+
309
657
  for (const r of routes) {
310
658
  if (r.rank !== 'unknown') continue;
311
659
  const isWrite = WRITE_METHODS.has(r.method);
@@ -322,6 +670,28 @@ function checkRouteRanks({ routesDir = ROUTES_DIR, modulesDir } = {}) {
322
670
  });
323
671
  }
324
672
 
673
+ // BV1.R109d — a `rank:` COMMENT claiming a PRIVILEGED rank over a route that
674
+ // carries no auth middleware. `rank: public` is the annotation's legitimate use —
675
+ // it asserts nothing is protected, which a comment can honestly say. Any other
676
+ // value asserts a gate exists, and the annotation arm is reached ONLY when no
677
+ // middleware was found, so that assertion is false by construction. Left
678
+ // unchecked, such a route passed the ungated-write invariant AND every exact pin.
679
+ for (const r of routes) {
680
+ if (r.via !== 'annotation' || r.rank === 'public') continue;
681
+ const isWrite = WRITE_METHODS.has(r.method);
682
+ findings.push({
683
+ severity: isWrite ? 'blocker' : 'major',
684
+ file: r.file,
685
+ route: r.key,
686
+ method: r.method,
687
+ rank: r.rank,
688
+ summary: `${r.key} (${r.file}) claims \`rank: ${r.rank}\` in a comment but carries NO auth middleware — no requireRank, requirePermission, requireBuilder or principal gate. A comment cannot gate a route. `
689
+ + (isWrite
690
+ ? 'A privileged write route gated only by prose is a hard fail (ADR 0016): add the real middleware, or annotate `rank: public` if it is genuinely unauthenticated.'
691
+ : 'Add the real middleware, or annotate `rank: public` if it is genuinely unauthenticated.'),
692
+ });
693
+ }
694
+
325
695
  // #999 — EXACT-rank pin on known privileged routes. A downgrade (e.g. an
326
696
  // archon route silently dropped to any-builder) trips a 'blocker' here even
327
697
  // though the route still classifies to a *known* rank, which the loop above
@@ -354,19 +724,20 @@ function checkRouteRanks({ routesDir = ROUTES_DIR, modulesDir } = {}) {
354
724
  route: key,
355
725
  method: key.split(' ')[0],
356
726
  rank: '(missing)',
357
- summary: `Privileged route ${key} is pinned to expect rank '${expected}' but is no longer present under src/bongos/routes/. `
727
+ summary: `Privileged route ${key} is pinned to expect '${expected}' but is no longer present under src/bongos/routes/. `
358
728
  + 'If it was intentionally removed/renamed, drop or update its entry in EXPECTED_RANKS; otherwise a protected route vanished.',
359
729
  });
360
730
  continue;
361
731
  }
362
- if (r.rank !== expected) {
732
+ const mismatch = pinMismatch(expected, r);
733
+ if (mismatch) {
363
734
  findings.push({
364
735
  severity: 'blocker',
365
736
  file: r.file,
366
737
  route: r.key,
367
738
  method: r.method,
368
739
  rank: r.rank,
369
- summary: `Privileged route ${r.key} (${r.file}) classifies as '${r.rank}' but is pinned to '${expected}'. `
740
+ summary: `Privileged route ${r.key} (${r.file}) ${mismatch}. `
370
741
  + 'This is a rank DOWNGRADE/CHANGE on a route that guards authority (ADR 0016). '
371
742
  + 'If the change is intentional, update EXPECTED_RANKS in route-rank-check.js in the same commit; otherwise restore the gate.',
372
743
  });
@@ -413,13 +784,25 @@ function toWorkerResult(result) {
413
784
  module.exports = {
414
785
  ROUTE_FILE_RE,
415
786
  MODULES_DIR,
787
+ CATALOG_PATH,
416
788
  KNOWN_RANKS,
417
789
  WRITE_METHODS,
418
790
  EXPECTED_RANKS,
419
791
  MODULE_EXPECTED_RANKS,
792
+ PERM_PIN_PREFIX,
793
+ PERMISSION_RANK_ORDER,
794
+ FLOOR_LABEL,
420
795
  SPECIALTY,
796
+ parsePermissionFloors,
797
+ permissionFloors,
798
+ permissionKeysIn,
799
+ rankForPermissions,
800
+ parsePin,
801
+ pinMismatch,
802
+ classifyGate,
421
803
  classifyRank,
422
804
  classifyText,
805
+ scanDeclarations,
423
806
  discoverRouteFiles,
424
807
  checkRouteRanks,
425
808
  toWorkerResult,
@@ -33,7 +33,18 @@ function parseCookie(req, name) {
33
33
  const cookieHeader = req.headers['cookie'] || '';
34
34
  for (const part of cookieHeader.split(';')) {
35
35
  const [k, v] = part.trim().split('=');
36
- if (k === name && v) return v;
36
+ if (k === name && v) {
37
+ // Cookie values are percent-ENCODED on write (Express `res.cookie` defaults
38
+ // to encodeURIComponent), so decode symmetrically on read. Without this, a
39
+ // return path like "/create" arrives as "%2Fcreate": safeReturnTo() sees a
40
+ // value that doesn't start with "/", tries `new URL()`, throws, and returns
41
+ // null — so a post-login redirect silently falls through to the default
42
+ // landing instead of the requested page (task 1002241; also un-breaks the
43
+ // #726 subdomain-return, whose full https:// URLs were encoded too). No-op
44
+ // for every value without a %-escape (hex state/session tokens, "1"). Guard
45
+ // a malformed lone "%" by falling back to the raw value.
46
+ try { return decodeURIComponent(v); } catch { return v; }
47
+ }
37
48
  }
38
49
  return null;
39
50
  }