@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
@@ -0,0 +1,388 @@
1
+ // modules/platform-identity/tests/platform-identity.mjs
2
+ //
3
+ // DB-free unit suite for the hub identity-provider domain (ADR 0141, task #1002106).
4
+ // Two layers: the pure crypto helpers (no DB), and the domain DB functions against
5
+ // a RECORDING FAKE pool (asserts the SQL shape + params + the security invariants
6
+ // without needing Postgres). Auto-run by scripts/gds/run-unit-tests.js ONLY when the
7
+ // platform-identity module is enabled; run standalone:
8
+ // node modules/platform-identity/tests/platform-identity.mjs
9
+ //
10
+ // These assertions ARE the security spec for the grader-outage window: single-use
11
+ // atomic redeem, code/secret hashed-at-rest, redirect_uri exact-match allow-list,
12
+ // and identity-only redemption (never a rank).
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+ import crypto from 'node:crypto';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const P = require('../platform-identity.js');
20
+
21
+ let passed = 0, failed = 0;
22
+ function t(name, fn) {
23
+ try { fn(); passed++; console.log(` PASS ${name}`); }
24
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
25
+ }
26
+ async function ta(name, fn) {
27
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
28
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
29
+ }
30
+
31
+ // A recording fake pool: returns queued responses in order and records every call.
32
+ function mockPool(responses = []) {
33
+ const calls = [];
34
+ let i = 0;
35
+ return {
36
+ calls,
37
+ query: async (text, params) => {
38
+ calls.push({ text: String(text), params: params || [] });
39
+ const r = responses[i++];
40
+ return r || { rows: [], rowCount: 0 };
41
+ },
42
+ };
43
+ }
44
+
45
+ console.log('pure crypto helpers:');
46
+
47
+ t('sha256hex is deterministic + input-sensitive', () => {
48
+ assert.equal(P.sha256hex('abc'), P.sha256hex('abc'));
49
+ assert.notEqual(P.sha256hex('abc'), P.sha256hex('abd'));
50
+ assert.equal(P.sha256hex('abc'), crypto.createHash('sha256').update('abc').digest('hex'));
51
+ });
52
+
53
+ t('constantTimeEqualHex: equal → true, different → false, different length → false', () => {
54
+ const h = P.sha256hex('secret');
55
+ assert.equal(P.constantTimeEqualHex(h, h), true);
56
+ assert.equal(P.constantTimeEqualHex(h, P.sha256hex('other')), false);
57
+ assert.equal(P.constantTimeEqualHex(h, h.slice(0, 10)), false);
58
+ assert.equal(P.constantTimeEqualHex('', ''), true);
59
+ });
60
+
61
+ t('newOpaqueCode: url-safe, ~256-bit, unique across calls', () => {
62
+ const a = P.newOpaqueCode();
63
+ const b = P.newOpaqueCode();
64
+ assert.notEqual(a, b);
65
+ assert.match(a, /^[A-Za-z0-9_-]+$/, 'must be base64url (no +,/,=)');
66
+ // 32 bytes base64url → 43 chars.
67
+ assert.equal(a.length, 43);
68
+ });
69
+
70
+ t('clientSecretValid: correct secret true; wrong/absent false', () => {
71
+ const secret = 'a'.repeat(64);
72
+ const client = { secret_hash: P.sha256hex(secret) };
73
+ assert.equal(P.clientSecretValid(client, secret), true);
74
+ assert.equal(P.clientSecretValid(client, 'wrong'), false);
75
+ assert.equal(P.clientSecretValid(null, secret), false);
76
+ assert.equal(P.clientSecretValid({}, secret), false);
77
+ });
78
+
79
+ t('redirectUriAllowed: exact match only (no prefix/substring/host tricks)', () => {
80
+ const client = { redirect_uris: ['https://proj.cloudbongos.com/api/gds/auth/web/callback'] };
81
+ assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com/api/gds/auth/web/callback'), true);
82
+ // A longer path that has the allowed URI as a prefix must NOT match.
83
+ assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com/api/gds/auth/web/callback/evil'), false);
84
+ // A shorter/host-only variant must NOT match.
85
+ assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com'), false);
86
+ // A look-alike host must NOT match.
87
+ assert.equal(P.redirectUriAllowed(client, 'https://proj.cloudbongos.com.evil.test/api/gds/auth/web/callback'), false);
88
+ assert.equal(P.redirectUriAllowed(null, 'https://x'), false);
89
+ assert.equal(P.redirectUriAllowed({ redirect_uris: [] }, 'https://x'), false);
90
+ });
91
+
92
+ t('clientSecretValid fails closed for unknown/secretless client (decoy path, no throw)', () => {
93
+ // The decoy-hash path must still return false and must not throw — the timing is
94
+ // equalized, but an absent client can never authenticate.
95
+ assert.equal(P.clientSecretValid(undefined, 'anything'), false);
96
+ assert.equal(P.clientSecretValid({ status: 'suspended' }, 'anything'), false);
97
+ assert.equal(P.clientSecretValid({ secret_hash: '' }, 'anything'), false);
98
+ });
99
+
100
+ console.log('\nsessionUsableForFederation (the browser-leg deny gate):');
101
+
102
+ t('a live full-authority web/cli session may federate', () => {
103
+ assert.deepEqual(
104
+ P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'web', system_role: null }),
105
+ { ok: true, reason: null });
106
+ assert.equal(P.sessionUsableForFederation({ github_id: 1, source: 'cli' }).ok, true);
107
+ });
108
+
109
+ t('a missing session → no_session (caller redirects to sign-in)', () => {
110
+ assert.deepEqual(P.sessionUsableForFederation(null), { ok: false, reason: 'no_session' });
111
+ assert.deepEqual(P.sessionUsableForFederation(undefined), { ok: false, reason: 'no_session' });
112
+ });
113
+
114
+ t('a deactivated account may NOT federate (mirrors requireBuilder status gate)', () => {
115
+ const r = P.sessionUsableForFederation({ github_id: 1, status: 'inactive', source: 'web' });
116
+ assert.deepEqual(r, { ok: false, reason: 'inactive' });
117
+ });
118
+
119
+ t('a dev-box-scoped token may NOT federate (outside the trust boundary)', () => {
120
+ const r = P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'box' });
121
+ assert.deepEqual(r, { ok: false, reason: 'box_scope' });
122
+ });
123
+
124
+ t('a service principal (system_role set) may NOT federate', () => {
125
+ const r = P.sessionUsableForFederation({ github_id: 1, status: 'active', source: 'web', system_role: 'bfg' });
126
+ assert.deepEqual(r, { ok: false, reason: 'service_principal' });
127
+ });
128
+
129
+ console.log('\naccounts:');
130
+
131
+ await ta('upsertAccount upserts on github_id and returns the row', async () => {
132
+ const row = { id: '1', github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a' };
133
+ const pool = mockPool([{ rows: [row] }]);
134
+ const out = await P.upsertAccount(
135
+ { githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a' }, { pool });
136
+ assert.deepEqual(out, row);
137
+ const c = pool.calls[0];
138
+ assert.match(c.text, /INSERT INTO platform_identity_accounts/);
139
+ assert.match(c.text, /ON CONFLICT \(github_id\) DO UPDATE/);
140
+ assert.deepEqual(c.params, [42, 'octocat', 'Octo', 'a']);
141
+ });
142
+
143
+ console.log('\nSSO client registry:');
144
+
145
+ await ta('registerClient hashes the secret at rest — never stores the plaintext', async () => {
146
+ const secret = 'z'.repeat(64);
147
+ const pool = mockPool([{ rows: [{ client_id: 'c1' }] }]);
148
+ await P.registerClient(
149
+ { clientId: 'c1', secret, name: 'Proj', redirectUris: ['https://p/cb'], origin: 'https://p', ownerGithubId: 7 },
150
+ { pool });
151
+ const c = pool.calls[0];
152
+ assert.match(c.text, /INSERT INTO platform_identity_sso_clients/);
153
+ assert.match(c.text, /ON CONFLICT \(client_id\) DO UPDATE/);
154
+ // The secret param must be the HASH, and the plaintext must not appear anywhere.
155
+ assert.equal(c.params[1], P.sha256hex(secret), 'secret_hash param must be sha256(secret)');
156
+ assert.ok(!c.params.includes(secret), 'plaintext secret must never be a query param');
157
+ assert.deepEqual(c.params[3], ['https://p/cb'], 'redirect_uris array passed through');
158
+ });
159
+
160
+ await ta('getClient selects by client_id and returns null when unknown', async () => {
161
+ const pool = mockPool([{ rows: [] }]);
162
+ const out = await P.getClient('nope', { pool });
163
+ assert.equal(out, null);
164
+ assert.match(pool.calls[0].text, /FROM platform_identity_sso_clients WHERE client_id = \$1/);
165
+ assert.deepEqual(pool.calls[0].params, ['nope']);
166
+ });
167
+
168
+ await ta('getClientByOrigin resolves the ACTIVE client by normalized origin (the CLI-mint audience source)', async () => {
169
+ const pool = mockPool([{ rows: [{ client_id: 'c-victim', status: 'active' }] }]);
170
+ const out = await P.getClientByOrigin('https://victim.com/', { pool });
171
+ assert.equal(out.client_id, 'c-victim');
172
+ // Trailing slash normalized; gated to active clients only.
173
+ assert.deepEqual(pool.calls[0].params, ['https://victim.com']);
174
+ assert.match(pool.calls[0].text, /WHERE origin = \$1 AND status = 'active'/);
175
+ });
176
+
177
+ await ta('getClientByOrigin returns null for an unregistered origin (federation refuses)', async () => {
178
+ const pool = mockPool([{ rows: [] }]);
179
+ assert.equal(await P.getClientByOrigin('https://evil.example', { pool }), null);
180
+ // Empty / missing origin never hits the DB.
181
+ const pool2 = mockPool([]);
182
+ assert.equal(await P.getClientByOrigin('', { pool: pool2 }), null);
183
+ assert.equal(pool2.calls.length, 0);
184
+ });
185
+
186
+ console.log('\nprojects catalog + Explore (§5):');
187
+
188
+ await ta('upsertProject upserts on normalized origin; update preserves tagline/art (COALESCE)', async () => {
189
+ const pool = mockPool([{ rows: [{ id: '1', origin: 'https://p', featured: false }] }]);
190
+ await P.upsertProject({ origin: 'https://p/', name: 'Proj' }, { pool });
191
+ const c = pool.calls[0];
192
+ assert.match(c.text, /INSERT INTO platform_identity_projects/);
193
+ assert.match(c.text, /ON CONFLICT \(origin\) DO UPDATE/);
194
+ assert.match(c.text, /tagline\s*=\s*COALESCE/);
195
+ assert.equal(c.params[0], 'https://p', 'trailing slash normalized');
196
+ // Never sets featured on upsert — that is Archon-only.
197
+ assert.doesNotMatch(c.text, /featured\s*=/);
198
+ });
199
+
200
+ await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only, no owner/status)', async () => {
201
+ const pool = mockPool([{ rows: [{ origin: 'https://p', name: 'Proj', tagline: 't', art_url: null }] }]);
202
+ const out = await P.listFeaturedProjects({ pool });
203
+ assert.equal(out.length, 1);
204
+ const c = pool.calls[0];
205
+ assert.match(c.text, /WHERE featured = true AND status = 'active'/);
206
+ assert.doesNotMatch(c.text, /owner_github_id/, 'public projection must not leak owner id');
207
+ });
208
+
209
+ await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
210
+ const pool = mockPool([{ rows: [{ id: '1', featured: true }] }]);
211
+ const out = await P.setProjectFeatured(1, true, { pool });
212
+ assert.equal(out.featured, true);
213
+ assert.match(pool.calls[0].text, /UPDATE platform_identity_projects SET featured = \$2/);
214
+ assert.deepEqual(pool.calls[0].params, [1, true]);
215
+ });
216
+
217
+ await ta('registerClient auto-catalogues the project (2nd query) when an origin is given', async () => {
218
+ const pool = mockPool([{ rows: [{ client_id: 'c1', origin: 'https://p' }] }, { rows: [{ id: '9' }] }]);
219
+ await P.registerClient({ clientId: 'c1', secret: 'z'.repeat(64), name: 'Proj', redirectUris: [], origin: 'https://p', ownerGithubId: 7 }, { pool });
220
+ assert.equal(pool.calls.length, 2, 'client insert + project upsert');
221
+ assert.match(pool.calls[1].text, /INSERT INTO platform_identity_projects/);
222
+ });
223
+
224
+ console.log('\nproject memberships (§3 check-in directory):');
225
+
226
+ await ta('upsertMembership upserts on (client_id, github_id) with a coarse role', async () => {
227
+ const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', role: 'owner' }] }]);
228
+ await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'owner' }, { pool });
229
+ const c = pool.calls[0];
230
+ assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
231
+ assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
232
+ assert.match(c.text, /last_active = now\(\)/);
233
+ assert.deepEqual(c.params, ['c1', 42, 'owner']);
234
+ });
235
+
236
+ await ta('upsertMembership clamps an unknown role to builder (never trusts a caller role blindly)', async () => {
237
+ const pool = mockPool([{ rows: [{}] }]);
238
+ await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'archon' }, { pool });
239
+ assert.equal(pool.calls[0].params[2], 'builder', 'a non-coarse role is clamped to builder');
240
+ });
241
+
242
+ await ta('removeMembership deletes the (client_id, github_id) row (self-service leave)', async () => {
243
+ const pool = mockPool([{ rowCount: 1 }]);
244
+ const n = await P.removeMembership('c1', 42, { pool });
245
+ assert.equal(n, 1);
246
+ assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships WHERE client_id = \$1 AND github_id = \$2/);
247
+ assert.deepEqual(pool.calls[0].params, ['c1', 42]);
248
+ });
249
+
250
+ await ta('listMembershipsForAccount joins projects + excludes pending', async () => {
251
+ const pool = mockPool([{ rows: [{ client_id: 'c1', role: 'builder', project_name: 'Proj' }] }]);
252
+ const out = await P.listMembershipsForAccount(42, { pool });
253
+ assert.equal(out.length, 1);
254
+ const c = pool.calls[0];
255
+ assert.match(c.text, /FROM platform_identity_project_memberships m/);
256
+ assert.match(c.text, /LEFT JOIN platform_identity_sso_clients c/);
257
+ assert.match(c.text, /role <> 'pending'/);
258
+ assert.deepEqual(c.params, [42]);
259
+ });
260
+
261
+ console.log('\ninvitations (§5 — pre-seed pending membership):');
262
+
263
+ await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
264
+ const pool = mockPool([{ rows: [{ id: '1', role: 'pending' }] }]);
265
+ const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
266
+ assert.equal(out.invited, true);
267
+ const c = pool.calls[0];
268
+ assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
269
+ assert.match(c.text, /VALUES \(\$1, \$2, 'pending'\)/);
270
+ assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO NOTHING/, 'must not downgrade an existing member');
271
+ });
272
+
273
+ await ta('inviteToProject reports already-member when the row exists (DO NOTHING → no row)', async () => {
274
+ const pool = mockPool([{ rows: [] }]);
275
+ const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
276
+ assert.deepEqual(out, { invited: false, alreadyMember: true });
277
+ });
278
+
279
+ await ta('resolveInvite accept flips ONLY a pending row to builder', async () => {
280
+ const pool = mockPool([{ rows: [{ id: '1' }] }]);
281
+ const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
282
+ assert.deepEqual(out, { resolved: true, accepted: true });
283
+ const c = pool.calls[0];
284
+ assert.match(c.text, /UPDATE platform_identity_project_memberships SET role = 'builder'/);
285
+ assert.match(c.text, /AND role = 'pending'/, 'only touches pending — never a real member');
286
+ });
287
+
288
+ await ta('resolveInvite decline deletes ONLY a pending row', async () => {
289
+ const pool = mockPool([{ rowCount: 1 }]);
290
+ const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
291
+ assert.deepEqual(out, { resolved: true, accepted: false });
292
+ assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND role = 'pending'/);
293
+ });
294
+
295
+ await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
296
+ const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'Octo' }] }]);
297
+ const out = await P.getAccountByLogin('octo', { pool });
298
+ assert.equal(out.github_id, '42');
299
+ assert.match(pool.calls[0].text, /lower\(github_login\) = lower\(\$1\)/);
300
+ });
301
+
302
+ console.log('\ncross-project activity (§4 scouting data plane):');
303
+
304
+ await ta('upsertActivity upserts per (client_id, github_id) with the talent snapshot', async () => {
305
+ const pool = mockPool([{ rows: [{ id: '1' }] }]);
306
+ await P.upsertActivity({ clientId: 'c1', githubId: 42, credits: 100, tasksShipped: 5, karma: 3, rank: 'metic', disciplines: ['engineer'] }, { pool });
307
+ const c = pool.calls[0];
308
+ assert.match(c.text, /INSERT INTO platform_identity_builder_project_activity/);
309
+ assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
310
+ assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer']]);
311
+ });
312
+
313
+ await ta('listScoutingRollup aggregates per builder across projects, ranked, joined to accounts', async () => {
314
+ const pool = mockPool([{ rows: [{ github_id: '42', github_login: 'octo', total_credits: 250, project_count: 2 }] }]);
315
+ const out = await P.listScoutingRollup({ pool, limit: 50 });
316
+ assert.equal(out.length, 1);
317
+ const c = pool.calls[0];
318
+ assert.match(c.text, /FROM platform_identity_builder_project_activity a/);
319
+ assert.match(c.text, /GROUP BY a\.github_id/);
320
+ assert.match(c.text, /sum\(a\.credits\)/);
321
+ assert.match(c.text, /ORDER BY total_credits DESC/);
322
+ assert.deepEqual(c.params, [50]);
323
+ });
324
+
325
+ console.log('\nauthorization codes:');
326
+
327
+ await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {
328
+ const pool = mockPool([{ rows: [] }]);
329
+ const code = await P.mintAuthCode(
330
+ { clientId: 'c1', githubId: 42, githubLogin: 'octocat', displayName: 'Octo', avatarUrl: 'a',
331
+ redirectUri: 'https://p/cb' },
332
+ { pool });
333
+ assert.match(code, /^[A-Za-z0-9_-]{43}$/, 'returns a fresh opaque code');
334
+ const c = pool.calls[0];
335
+ assert.match(c.text, /INSERT INTO platform_identity_auth_codes/);
336
+ // param 0 is code_hash — it must equal sha256(returned code), and the plaintext
337
+ // code must NOT be among the params.
338
+ assert.equal(c.params[0], P.sha256hex(code), 'code_hash param must be sha256(code)');
339
+ assert.ok(!c.params.includes(code), 'plaintext code must never be a query param');
340
+ // Binding: client_id, github_id, redirect_uri present.
341
+ assert.equal(c.params[1], 'c1');
342
+ assert.equal(c.params[2], 42);
343
+ assert.equal(c.params[3], 'https://p/cb');
344
+ // expires_at (last param) is a Date in the near future (~<= default TTL).
345
+ const exp = c.params[7];
346
+ assert.ok(exp instanceof Date, 'expires_at is a Date');
347
+ const dt = exp.getTime() - Date.now();
348
+ assert.ok(dt > 0 && dt <= P.DEFAULT_CODE_TTL_MS + 1000, `TTL ~${P.DEFAULT_CODE_TTL_MS}ms`);
349
+ });
350
+
351
+ await ta('redeemAuthCode is an ATOMIC single-use redeem, bound + identity-only', async () => {
352
+ const identity = { github_id: '42', github_login: 'octocat', display_name: 'Octo', avatar_url: 'a', redirect_uri: 'https://p/cb' };
353
+ const pool = mockPool([{ rows: [identity] }]);
354
+ const code = 'plain-code-value';
355
+ const out = await P.redeemAuthCode({ code, clientId: 'c1' }, { pool });
356
+ assert.deepEqual(out, identity);
357
+ const c = pool.calls[0];
358
+ // Atomic single-use: one UPDATE that sets consumed_at AND guards on it being NULL.
359
+ assert.match(c.text, /UPDATE platform_identity_auth_codes/);
360
+ assert.match(c.text, /SET consumed_at = now\(\)/);
361
+ assert.match(c.text, /consumed_at IS NULL/);
362
+ assert.match(c.text, /expires_at > now\(\)/);
363
+ assert.match(c.text, /RETURNING/);
364
+ // Looked up by the HASH of the presented code + bound to the client.
365
+ assert.equal(c.params[0], P.sha256hex(code), 'looked up by sha256(code)');
366
+ assert.equal(c.params[1], 'c1');
367
+ // Identity-only: the RETURNING clause must not leak a rank / credits / token.
368
+ assert.doesNotMatch(c.text, /\brank\b/i, 'redeem must never return a rank');
369
+ assert.doesNotMatch(c.text, /\bcredits?\b/i, 'redeem must never return credits');
370
+ assert.doesNotMatch(c.text, /\btoken\b/i, 'redeem must never return a session token');
371
+ });
372
+
373
+ await ta('redeemAuthCode returns null on no-match (consumed/expired/wrong client)', async () => {
374
+ const pool = mockPool([{ rows: [] }]);
375
+ const out = await P.redeemAuthCode({ code: 'x', clientId: 'c1' }, { pool });
376
+ assert.equal(out, null);
377
+ });
378
+
379
+ await ta('sweepExpiredCodes deletes expired + long-consumed rows', async () => {
380
+ const pool = mockPool([{ rowCount: 3 }]);
381
+ const n = await P.sweepExpiredCodes({ pool });
382
+ assert.equal(n, 3);
383
+ assert.match(pool.calls[0].text, /DELETE FROM platform_identity_auth_codes/);
384
+ assert.match(pool.calls[0].text, /expires_at < now\(\)/);
385
+ });
386
+
387
+ console.log(`\nplatform-identity: ${passed} passed, ${failed} failed`);
388
+ if (failed > 0) process.exit(1);
File without changes
@@ -137,6 +137,12 @@ module.exports = function buildSecurityReportsRouter() {
137
137
  api.requireBuilder,
138
138
  api.requireRank('archon'),
139
139
  async (req, res) => {
140
+ // drachmae_awarded is OPTIONAL and deliberately UNTYPED here: the handler
141
+ // accepts a number, a stringified number, '' and null alike, and the inline
142
+ // check below is what enforces finite/non-negative. Declaring a type would
143
+ // narrow the live contract; declaring the field at all is what lets the
144
+ // validator reject UNKNOWN fields, which is the actual gap (BV1.R109c).
145
+ if (validateOrRespond(req, res, { drachmae_awarded: {} })) return;
140
146
  const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
141
147
  if (id === null) return;
142
148
  const drachmaeRaw = req.body?.drachmae_awarded;
@@ -221,6 +227,10 @@ module.exports = function buildSecurityReportsRouter() {
221
227
  api.requireBuilder,
222
228
  api.requireRank('archon'),
223
229
  async (req, res) => {
230
+ // task_id is REQUIRED but untyped: the handler accepts a number or a
231
+ // stringified number and coerces below, so a type would narrow the live
232
+ // contract. Same idiom as POST /tasks/:id/dependencies (BV1.R109c).
233
+ if (validateOrRespond(req, res, { task_id: { required: true } })) return;
224
234
  const id = parseId(req, res, { code: 'bad_report_id', positiveInt: true });
225
235
  if (id === null) return;
226
236
  const taskIdRaw = req.body?.task_id;
@@ -109,41 +109,184 @@ async function upsertSessionRecord(opts) {
109
109
  return upsertSessionRecordTx(pool, opts);
110
110
  }
111
111
 
112
+ // awardSessionTokenRewardTx — resolve the economy `reward` port and book the
113
+ // idempotent cost-plus session token reward on `client`, mirroring the granted
114
+ // drachmae onto the session_records row for observability (the authoritative
115
+ // payout is the credit_log row; token_reward_drachmae is a mirror — migration
116
+ // 091). Returns the reward result ({ drachmae, awarded, ... }). Degrades to a
117
+ // clean no-op when economy is disabled (no `reward` provider). Shared by BOTH
118
+ // pay-on-land triggers (the upload-side gate below + the task.shipped listener
119
+ // via awardDeferredSessionRewardForTask) so both book the reward identically;
120
+ // per-session watermark idempotency (the SELECT fast-path + the
121
+ // uniq_credit_session_token_reward_basis partial index) makes a double-attempt
122
+ // safe, and lets a later, costlier ship in the same session book its increment.
123
+ // task 1676: pass totalTokens so the reward can fall back to pricing the
124
+ // session's real token total when modelUsage is empty/unpriceable — the
125
+ // dev-box/CI agent path that historically paid $0 cost-plus (ADR 0097).
126
+ async function awardSessionTokenRewardTx(client, { builderId, sessionId, modelUsage, totalTokens }) {
127
+ const reward = seams.resolveOptional('reward');
128
+ if (!reward) return { drachmae: 0, trueCostUsd: 0, awarded: false, unknownModels: [], deferred: false };
129
+ const tokenReward = await reward.awardSessionTokenReward(
130
+ client, builderId, sessionId, modelUsage || {}, totalTokens || 0
131
+ );
132
+ if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) {
133
+ await client.query(
134
+ `UPDATE session_records
135
+ SET token_reward_drachmae = token_reward_drachmae + $2
136
+ WHERE session_id = $1 AND builder_id = $3`,
137
+ [sessionId, tokenReward.drachmae, builderId]
138
+ );
139
+ }
140
+ return tokenReward;
141
+ }
142
+
112
143
  // upsertSessionRecordWithReward — ONE transaction: upsert the session digest
113
- // (incl. model_usage), then compute + award the idempotent cost-plus token
114
- // reward (task 1005, ADR 0054) and mirror token_reward_drachmae onto the row.
115
- // Returns the record augmented with { tokenReward }. The token reward is a
116
- // SEPARATE credit_log row (reason='session.token_reward'); total_credits is
144
+ // (incl. model_usage), then — the UPLOAD-side half of ADR 0120 "pay on land" —
145
+ // book the cost-plus token reward (task 1005, ADR 0054) ONLY IF the code has
146
+ // already LANDED. Returns the record augmented with { tokenReward }. The reward
147
+ // is a SEPARATE credit_log row (reason='session.token_reward'); total_credits is
117
148
  // bumped by the credit_log trigger only, never double-counted here.
149
+ //
150
+ // ADR 0120: the reward is contingent on the SHIP, not the upload. ship.js orders
151
+ // differ by mode: sync/laptop mode LANDS the task (status='shipped') then uploads
152
+ // the digest, so at upload time a task is already shipped → we award now. ci/async
153
+ // mode UPLOADS the digest first and lands later, so no task is shipped yet → we
154
+ // DEFER (record the digest, award nothing) and the reward books when the
155
+ // task.shipped event fires (routes/sessions.js's listener →
156
+ // awardDeferredSessionRewardForTask). Both triggers call the SAME idempotent
157
+ // award, so exactly-once is guaranteed by the per-session_id credit_log
158
+ // idempotency — never by which trigger wins the race.
118
159
  async function upsertSessionRecordWithReward(opts) {
119
160
  return api.withTx(async (client) => {
120
161
  const record = await upsertSessionRecordTx(client, opts);
121
- let tokenReward = { drachmae: 0, trueCostUsd: 0, awarded: false, unknownModels: [] };
162
+ // Deferred by default (ADR 0120): the digest is recorded, but the token
163
+ // reward only books once at least one of the session's tasks has landed.
164
+ let tokenReward = { drachmae: 0, trueCostUsd: 0, awarded: false, unknownModels: [], deferred: true };
122
165
  if (record) {
123
166
  // record is non-null only when the ownership guard passed (the row is the
124
- // caller's), so the reward is always attributed to the verified owner.
125
- // Token reward lives in modules/economy/ (BV1.R77 / ADR 0093 §1-2):
126
- // resolve the `reward` kernel port and award inside this same txn (the
127
- // module↔module seam — no sibling require).
128
- // task 1676: pass totalTokens so the reward can fall back to pricing the
129
- // session's real token total when modelUsage is empty/unpriceable — the
130
- // dev-box/CI agent path that historically paid $0 cost-plus (ADR 0097).
131
- tokenReward = await seams.resolveOptional('reward').awardSessionTokenReward(
132
- client, opts.builderId, opts.sessionId, opts.modelUsage || {}, opts.totalTokens || 0
133
- );
134
- if (tokenReward.awarded && tokenReward.drachmae > 0) {
135
- await client.query(
136
- `UPDATE session_records
137
- SET token_reward_drachmae = token_reward_drachmae + $2
138
- WHERE session_id = $1 AND builder_id = $3`,
139
- [opts.sessionId, tokenReward.drachmae, opts.builderId]
167
+ // caller's), so any reward is always attributed to the verified owner.
168
+ // Gate on the SHIP: award now iff at least one of the session's task_ids
169
+ // has reached status='shipped'. Otherwise leave the deferred default.
170
+ const ids = Array.isArray(record.task_ids)
171
+ ? record.task_ids.map((n) => Number(n)).filter(Number.isFinite)
172
+ : [];
173
+ let landed = false;
174
+ if (ids.length > 0) {
175
+ const { rows } = await client.query(
176
+ `SELECT 1 FROM tasks WHERE id = ANY($1::bigint[]) AND status = 'shipped' LIMIT 1`,
177
+ [ids]
140
178
  );
179
+ landed = rows.length > 0;
180
+ }
181
+ if (landed) {
182
+ tokenReward = await awardSessionTokenRewardTx(client, {
183
+ builderId: opts.builderId,
184
+ sessionId: opts.sessionId,
185
+ modelUsage: opts.modelUsage || {},
186
+ totalTokens: opts.totalTokens || 0,
187
+ });
141
188
  }
142
189
  }
143
190
  return record ? { ...record, tokenReward } : null;
144
191
  });
145
192
  }
146
193
 
194
+ // awardDeferredSessionRewardForTask — the LAND-side half of ADR 0120 "pay on
195
+ // land". When a task reaches status='shipped', routes/sessions.js's task.shipped
196
+ // listener calls this with the shipped task id. It finds every session_record
197
+ // that shipped this task and books the cost-plus token reward for each — the
198
+ // ci/async path, where the digest was uploaded (and DEFERRED) before the code
199
+ // landed. Opens its own transaction.
200
+ //
201
+ // We do NOT pre-filter on token_reward_drachmae: awardSessionTokenReward is
202
+ // idempotent per session_id, so a session that already collected at upload time
203
+ // (sync mode) — or a re-fired event — is a clean no-op. The credit_log is the
204
+ // single source of truth for "already paid"; the mirror column is only
205
+ // observability, so it is never the idempotency key. Returns { sessions, awarded }
206
+ // (rows scanned, rewards freshly booked).
207
+ async function awardDeferredSessionRewardForTask(taskId) {
208
+ const id = Number(taskId);
209
+ if (!Number.isFinite(id)) return { sessions: 0, awarded: 0 };
210
+ return api.withTx(async (client) => {
211
+ const { rows } = await client.query(
212
+ `SELECT session_id, builder_id, model_usage, total_tokens
213
+ FROM session_records
214
+ WHERE task_ids @> ARRAY[$1]::bigint[]`,
215
+ [id]
216
+ );
217
+ let awarded = 0;
218
+ for (const r of rows) {
219
+ const tokenReward = await awardSessionTokenRewardTx(client, {
220
+ builderId: r.builder_id,
221
+ sessionId: r.session_id,
222
+ modelUsage: r.model_usage || {},
223
+ totalTokens: r.total_tokens || 0,
224
+ });
225
+ if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
226
+ }
227
+ return { sessions: rows.length, awarded };
228
+ });
229
+ }
230
+
231
+ // reconcileUnbookedSessionRewards — the STATE-BASED safety net for the deferred
232
+ // cost-plus session-token reward (ADR 0120). Today the reward books via two LIVE
233
+ // triggers only: upsertSessionRecordWithReward (upload-time, iff a task the
234
+ // session shipped is already status='shipped') and awardDeferredSessionRewardForTask
235
+ // (driven by the in-process `task.shipped` event listener). Both MISS a task that
236
+ // reaches status='shipped' OUT OF BAND — a direct-SQL / hotpatch flip, or a ship
237
+ // performed in a DIFFERENT process — because no `task.shipped` event fires in THIS
238
+ // server to release the deferred reward. Such a session's drachmae then strand
239
+ // PERMANENTLY even though the code genuinely landed. (This is routine on a box that
240
+ // hotpatches / ships with --skip-grade.) This idempotent sweep closes that gap:
241
+ // it finds every session whose task is now genuinely 'shipped' but that carries no
242
+ // session.token_reward row, and books it.
243
+ //
244
+ // Mirrors the task 1679 achievements reconcile — the accepted precedent for
245
+ // "the event trigger missed some ships, so reconcile from durable STATE, not the
246
+ // event." Design invariants:
247
+ // • STRICTLY pay-on-land: the JOIN requires t.status='shipped', so a task stuck
248
+ // at 'confirmed' is NEVER paid (ADR 0120 — never pay for code that didn't land).
249
+ // • Touches NO GitHub and sits behind NO push-credential guard — its whole point
250
+ // is to run on a CREDENTIAL-LESS box (the cloudbongos shape) and recover
251
+ // already-shipped work the reconciler-push-gap left unbooked.
252
+ // • Idempotent via the SAME watermark guard awardSessionTokenReward enforces (the
253
+ // SELECT fast-path + the uniq_credit_session_token_reward_basis partial index),
254
+ // so a re-sweep — or a race with a live trigger — never double-pays.
255
+ // Returns { scanned, awarded }.
256
+ async function reconcileUnbookedSessionRewards({ limit = 200 } = {}) {
257
+ // undefined → the destructuring default (200); a finite number clamps to
258
+ // [1, 1000] (so an explicit 0/negative floors at 1, never an unbounded scan);
259
+ // garbage (NaN, 'abc') falls back to 200.
260
+ const n = Number(limit);
261
+ const cap = Number.isFinite(n) ? Math.max(1, Math.min(1000, Math.trunc(n))) : 200;
262
+ return api.withTx(async (client) => {
263
+ const { rows } = await client.query(
264
+ `SELECT DISTINCT sr.session_id, sr.builder_id, sr.model_usage, sr.total_tokens
265
+ FROM session_records sr
266
+ JOIN tasks t ON t.id = ANY(sr.task_ids) AND t.status = 'shipped'
267
+ WHERE sr.session_id IS NOT NULL
268
+ AND NOT EXISTS (
269
+ SELECT 1 FROM credit_log cl
270
+ WHERE cl.session_id = sr.session_id
271
+ AND cl.reason = 'session.token_reward')
272
+ ORDER BY sr.session_id
273
+ LIMIT $1`,
274
+ [cap]
275
+ );
276
+ let awarded = 0;
277
+ for (const r of rows) {
278
+ const tokenReward = await awardSessionTokenRewardTx(client, {
279
+ builderId: r.builder_id,
280
+ sessionId: r.session_id,
281
+ modelUsage: r.model_usage || {},
282
+ totalTokens: r.total_tokens || 0,
283
+ });
284
+ if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
285
+ }
286
+ return { scanned: rows.length, awarded };
287
+ });
288
+ }
289
+
147
290
  // ---- 6D.2: session search + fetch (read side, ADR 0027) -------------------
148
291
 
149
292
  // Safe ORDER BY whitelist — the ONLY value interpolated into the search SQL, so
@@ -314,6 +457,8 @@ module.exports = {
314
457
  // write side (6D.1)
315
458
  upsertSessionRecord,
316
459
  upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
460
+ awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
461
+ reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
317
462
  // search + fetch (6D.2)
318
463
  searchSessions,
319
464
  summarizeSessions,
@@ -4,7 +4,7 @@
4
4
  "description": "The session-records corpus + its data plane (POST /sessions upload, the Archon-gated cross-builder /sessions/search + /sessions/:id deep-dive), the BFG 'read-the-room' cross-builder scorer (ADR 0026 §6B), and the session-pulse / stale-claim heartbeat helpers. A core-domain module carved out of the GDS core in BV1.R82 (ADR 0093 §1); default-on because every instance records its builders' sessions for the BFG. Like the memory module, sessions registers no port — core never calls back into it: the ship-time digest write is an HTTP POST to the module's own /sessions route, and the only outward dependency is the economy `reward` kernel PORT it resolves (session token reward + sumCreditsForTasks) at upload time.",
5
5
  "coreVersion": "^1.10.0",
6
6
  "default": true,
7
- "contributes": { "routes": ["sessions"] },
7
+ "contributes": { "routes": ["sessions"], "pollers": ["session-reward-reconcile"] },
8
8
  "provides": [],
9
9
  "consumes": []
10
10
  }