@bongos/core 1.18.3 → 1.18.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (471) hide show
  1. package/.bongos-core.json +1576 -286
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +4 -2
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +6 -1
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  27. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  28. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  29. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  30. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  31. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  32. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  33. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  34. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  35. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  36. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  37. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  38. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  39. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  40. package/docs/adr/README.md +10 -0
  41. package/docs/api/openapi.json +843 -17
  42. package/docs/api-reference.md +23 -10
  43. package/docs/architecture.md +4 -2
  44. package/docs/branding-contract.md +3 -1
  45. package/docs/canonical-permissions.md +21 -8
  46. package/docs/file-map.md +8 -0
  47. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  48. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  49. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  50. package/docs/onboarding/diagrams/assertions.json +4 -7
  51. package/docs/onboarding/drachmae-and-karma.md +3 -3
  52. package/docs/onboarding/primer.md +17 -16
  53. package/docs/onboarding/slash-commands.md +124 -0
  54. package/docs/recipes/builder-box-lifecycle.md +23 -0
  55. package/docs/recipes/devbox-app-release.md +14 -4
  56. package/docs/recipes/instance-first-run.md +16 -0
  57. package/docs/recipes/local-dev.md +81 -0
  58. package/docs/recipes/ops-gotchas.md +19 -1
  59. package/docs/recipes/overnight-builder.md +1 -1
  60. package/docs/recipes/parallel-goal-waves.md +174 -0
  61. package/docs/recipes/ship-and-regrade.md +2 -2
  62. package/docs/recipes/standalone-live-docs.md +48 -0
  63. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  64. package/migrations/003_pms.sql +6 -7
  65. package/migrations/006_pms_v2.sql +3 -31
  66. package/migrations/core_193_task_needs_rebase.sql +28 -0
  67. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  68. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  69. package/migrations/core_196_session_reward_topup.sql +69 -0
  70. package/modules/autonomy/pollers/routine-timers.js +9 -8
  71. package/modules/builder-settings/wandering-prefs.js +61 -18
  72. package/modules/dev-box/app/README.md +73 -0
  73. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  74. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  75. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  76. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  77. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  78. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  79. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  80. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  81. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  82. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  83. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  84. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  85. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  86. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  87. package/modules/dev-box/app/build-resources/icon.png +0 -0
  88. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  89. package/modules/dev-box/app/electron-builder.yml +95 -0
  90. package/modules/dev-box/app/package-lock.json +5291 -0
  91. package/modules/dev-box/app/package.json +30 -0
  92. package/modules/dev-box/app/renderer/app.js +319 -0
  93. package/modules/dev-box/app/renderer/index.html +45 -0
  94. package/modules/dev-box/app/renderer/styles.css +246 -0
  95. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  96. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  97. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  98. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  99. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  100. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  101. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  102. package/modules/dev-box/app/src/box-controller.js +287 -0
  103. package/modules/dev-box/app/src/brand.generated.json +13 -0
  104. package/modules/dev-box/app/src/brand.js +16 -0
  105. package/modules/dev-box/app/src/config.js +19 -0
  106. package/modules/dev-box/app/src/gds-client.js +90 -0
  107. package/modules/dev-box/app/src/main.js +610 -0
  108. package/modules/dev-box/app/src/pairing.js +57 -0
  109. package/modules/dev-box/app/src/preload.js +29 -0
  110. package/modules/dev-box/app/src/session-store.js +58 -0
  111. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  112. package/modules/dev-box/app/src/task-picker.js +70 -0
  113. package/modules/dev-box/app/src/tray.js +99 -0
  114. package/modules/dev-box/app/src/updater.js +55 -0
  115. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  116. package/modules/dev-box/app/test/unit.mjs +526 -0
  117. package/modules/dev-box/box-access.js +121 -4
  118. package/modules/dev-box/boxes.js +20 -0
  119. package/modules/dev-box/module.json +1 -1
  120. package/modules/dev-box/routes/box.js +87 -3
  121. package/modules/economy/credits.js +116 -25
  122. package/modules/economy/reward.js +8 -0
  123. package/modules/economy/streak.js +2 -1
  124. package/modules/governance/CLAUDE.md +15 -0
  125. package/modules/governance/catalog.js +419 -0
  126. package/modules/governance/config.js +174 -0
  127. package/modules/governance/db.js +125 -0
  128. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  129. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  130. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  131. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  132. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  133. package/modules/governance/module.json +10 -0
  134. package/modules/governance/protected-surfaces.js +88 -0
  135. package/modules/governance/protected-surfaces.json +80 -0
  136. package/modules/governance/rank-roles.js +83 -0
  137. package/modules/governance/resolver.js +221 -0
  138. package/modules/governance/routes/governance.js +89 -0
  139. package/modules/governance/routes/role-sync.js +49 -0
  140. package/modules/governance/session-scopes.js +127 -0
  141. package/modules/governance/session-scopes.json +38 -0
  142. package/modules/hall-ui/public/atlas.css +117 -0
  143. package/modules/hall-ui/public/atlas.html +3 -114
  144. package/modules/hall-ui/public/brand-apply.js +34 -6
  145. package/modules/hall-ui/public/builders.js +187 -219
  146. package/modules/hall-ui/public/diagrams.html +2 -0
  147. package/modules/hall-ui/public/dom-utils.js +27 -0
  148. package/modules/hall-ui/public/drachmae.html +2 -0
  149. package/modules/hall-ui/public/drachmae.js +54 -1
  150. package/modules/hall-ui/public/gate.css +98 -0
  151. package/modules/hall-ui/public/gate.html +3 -95
  152. package/modules/hall-ui/public/goal-inbox.js +4 -1
  153. package/modules/hall-ui/public/goals-page.js +626 -114
  154. package/modules/hall-ui/public/goals.css +79 -1
  155. package/modules/hall-ui/public/goals.html +38 -11
  156. package/modules/hall-ui/public/hall-widgets.js +62 -52
  157. package/modules/hall-ui/public/harbor.css +123 -0
  158. package/modules/hall-ui/public/harbor.html +3 -120
  159. package/modules/hall-ui/public/index.html +11 -4
  160. package/modules/hall-ui/public/instances-panel.js +4 -1
  161. package/modules/hall-ui/public/modules.css +63 -0
  162. package/modules/hall-ui/public/modules.html +3 -60
  163. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  164. package/modules/hall-ui/public/not-ready.html +2 -0
  165. package/modules/hall-ui/public/pair.css +73 -0
  166. package/modules/hall-ui/public/pair.html +3 -70
  167. package/modules/hall-ui/public/palette.js +302 -0
  168. package/modules/hall-ui/public/primer.css +13 -0
  169. package/modules/hall-ui/public/primer.html +2 -0
  170. package/modules/hall-ui/public/profile.html +2 -0
  171. package/modules/hall-ui/public/ranks.html +2 -0
  172. package/modules/hall-ui/public/roadmap.css +179 -0
  173. package/modules/hall-ui/public/roadmap.html +9 -172
  174. package/modules/hall-ui/public/roadmap.js +329 -316
  175. package/modules/hall-ui/public/sessions.css +16 -0
  176. package/modules/hall-ui/public/sessions.html +3 -13
  177. package/modules/hall-ui/public/settings.html +2 -0
  178. package/modules/hall-ui/public/settings.js +4 -4
  179. package/modules/hall-ui/public/shell.js +143 -18
  180. package/modules/hall-ui/public/style.css +231 -39
  181. package/modules/hall-ui/public/task-detail.js +347 -26
  182. package/modules/hall-ui/public/task.html +6 -1
  183. package/modules/hall-ui/public/watch.html +2 -0
  184. package/modules/hall-ui/public/work.html +8 -6
  185. package/modules/hall-ui/public/work.js +1 -1
  186. package/modules/ideas/inbox.js +61 -11
  187. package/modules/ideas/routes/inbox.js +23 -0
  188. package/modules/lifecycle/claim-eligibility.js +72 -0
  189. package/modules/lifecycle/db.js +519 -139
  190. package/modules/lifecycle/done-when.js +18 -3
  191. package/modules/lifecycle/github-push.js +19 -2
  192. package/modules/lifecycle/goal-advisory.js +114 -0
  193. package/modules/lifecycle/goal-authz.js +62 -0
  194. package/modules/lifecycle/lifecycle.js +9 -0
  195. package/modules/lifecycle/publish-reconciler.js +109 -6
  196. package/modules/lifecycle/routes/claims.js +68 -23
  197. package/modules/lifecycle/routes/done-when.js +110 -15
  198. package/modules/lifecycle/routes/goals.js +8 -38
  199. package/modules/lifecycle/routes/tasks.js +38 -6
  200. package/modules/lifecycle/ship-card.js +6 -0
  201. package/modules/onboarding/onboarding-state.js +1 -1
  202. package/modules/platform-identity/CLAUDE.md +15 -0
  203. package/modules/platform-identity/hub-device.js +147 -0
  204. package/modules/platform-identity/hub-keys.js +151 -0
  205. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  206. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  207. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  208. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  209. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  210. package/modules/platform-identity/module.json +18 -0
  211. package/modules/platform-identity/platform-identity.js +531 -0
  212. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  213. package/modules/platform-identity/routes/my-projects.js +140 -0
  214. package/modules/platform-identity/routes/projects.js +107 -0
  215. package/modules/platform-identity/routes/scouting.js +35 -0
  216. package/modules/platform-identity/routes/sso.js +402 -0
  217. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  218. package/modules/platform-identity/ui/.gitkeep +0 -0
  219. package/modules/security/routes/reports.js +10 -0
  220. package/modules/sessions/db.js +166 -21
  221. package/modules/sessions/module.json +1 -1
  222. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  223. package/modules/sessions/routes/sessions.js +33 -0
  224. package/package-lock.json +2 -2
  225. package/package.json +1 -1
  226. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  227. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  228. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  229. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  230. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  231. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  232. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  233. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  234. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  235. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  236. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  237. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  238. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  239. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  270. package/public-landing/create.html +51 -58
  271. package/public-landing/gate/README.md +137 -0
  272. package/public-landing/gate/dispmaps/bar.png +0 -0
  273. package/public-landing/gate/dispmaps/flat.png +0 -0
  274. package/public-landing/gate/dispmaps/key.png +0 -0
  275. package/public-landing/gate/index.html +587 -0
  276. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  277. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  278. package/public-landing/gate/space.css +799 -0
  279. package/public-landing/gate/space.js +1826 -0
  280. package/public-landing/gate/sprites/enter.png +0 -0
  281. package/public-landing/gate/sprites/search.png +0 -0
  282. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  283. package/public-landing/gate/sprites/tile.png +0 -0
  284. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  285. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  286. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  287. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  288. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  289. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  290. package/scripts/gds/abandon.js +6 -4
  291. package/scripts/gds/api.js +10 -5
  292. package/scripts/gds/api.ps1 +26 -0
  293. package/scripts/gds/api.sh +6 -0
  294. package/scripts/gds/audit-ships.js +7 -1
  295. package/scripts/gds/backfill-sessions.js +78 -18
  296. package/scripts/gds/backup.js +23 -1
  297. package/scripts/gds/box-connect-lib.js +124 -31
  298. package/scripts/gds/box-sync.js +66 -0
  299. package/scripts/gds/box.js +225 -53
  300. package/scripts/gds/cf-tunnel.js +145 -6
  301. package/scripts/gds/claim.js +34 -3
  302. package/scripts/gds/cli-lib.js +161 -27
  303. package/scripts/gds/code.js +6 -2
  304. package/scripts/gds/deploy-divergence.js +209 -0
  305. package/scripts/gds/dev-lib.js +309 -0
  306. package/scripts/gds/dev.js +322 -0
  307. package/scripts/gds/doctor.js +90 -61
  308. package/scripts/gds/fetch-art-key.js +1 -1
  309. package/scripts/gds/fetch-sound-prefs.js +2 -2
  310. package/scripts/gds/fitness.js +228 -12
  311. package/scripts/gds/gds-db-backup.service +16 -1
  312. package/scripts/gds/gds-db-offsite.service +9 -1
  313. package/scripts/gds/gen-api-client.js +1 -1
  314. package/scripts/gds/gen-api-docs.js +36 -14
  315. package/scripts/gds/gen-diagrams.js +45 -28
  316. package/scripts/gds/gen-file-map.js +2 -1
  317. package/scripts/gds/gen-freshness.js +32 -0
  318. package/scripts/gds/gen-repo-map.js +3 -2
  319. package/scripts/gds/gen-session-index.js +14 -5
  320. package/scripts/gds/hooks/pre-push.js +2 -2
  321. package/scripts/gds/init.js +60 -3
  322. package/scripts/gds/install-git-hooks.js +1 -1
  323. package/scripts/gds/key-card.js +15 -6
  324. package/scripts/gds/key-status.js +1 -1
  325. package/scripts/gds/linkify-refs.js +35 -9
  326. package/scripts/gds/login.js +16 -1
  327. package/scripts/gds/paste-token.js +7 -1
  328. package/scripts/gds/preflight.js +126 -0
  329. package/scripts/gds/provision.js +123 -12
  330. package/scripts/gds/publish-manifest.js +7 -1
  331. package/scripts/gds/reauth.js +7 -3
  332. package/scripts/gds/regen-instance-docs.js +47 -0
  333. package/scripts/gds/run-unit-tests.js +4 -2
  334. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  335. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  336. package/scripts/gds/sequence.js +425 -0
  337. package/scripts/gds/session-cost.js +10 -1
  338. package/scripts/gds/setup.js +31 -5
  339. package/scripts/gds/shell.js +5 -3
  340. package/scripts/gds/ship.js +319 -29
  341. package/scripts/gds/smoke-gds.js +139 -8
  342. package/scripts/gds/start.js +171 -22
  343. package/scripts/gds/status.js +1 -1
  344. package/scripts/gds/triage.js +10 -9
  345. package/scripts/gds/upgrade.js +124 -2
  346. package/scripts/migrate.sh +99 -14
  347. package/scripts/status-mirror-sync.js +114 -84
  348. package/src/bongos/api-errors.js +1 -0
  349. package/src/bongos/auth.js +447 -102
  350. package/src/bongos/db-kernel.js +27 -0
  351. package/src/bongos/db.js +39 -0
  352. package/src/bongos/module-scope-map.js +27 -1
  353. package/src/bongos/permission-path-check.js +136 -102
  354. package/src/bongos/repo-info.js +47 -11
  355. package/src/bongos/route-rank-check.js +429 -46
  356. package/src/bongos/routes/_helpers.js +12 -1
  357. package/src/bongos/routes/auth.js +219 -2
  358. package/src/bongos/routes/backup.js +58 -2
  359. package/src/bongos/routes/me.js +35 -1
  360. package/src/bongos/serve-internal.js +84 -15
  361. package/src/branding.js +32 -0
  362. package/src/instance-config.js +30 -0
  363. package/src/module-api.js +124 -1
  364. package/src/platform-server.js +86 -9
  365. package/tests/abandon_task.mjs +59 -3
  366. package/tests/api_cli_flags.mjs +53 -0
  367. package/tests/auth_page_gate.mjs +13 -0
  368. package/tests/backfill_sessions.mjs +127 -1
  369. package/tests/backup_cli.mjs +54 -0
  370. package/tests/bongos_box_connect.mjs +77 -0
  371. package/tests/box_access.mjs +130 -1
  372. package/tests/box_credential.mjs +125 -0
  373. package/tests/box_host_keys.mjs +24 -0
  374. package/tests/box_infra_resolve.mjs +149 -0
  375. package/tests/box_scope_session.mjs +6 -2
  376. package/tests/box_sync.mjs +55 -0
  377. package/tests/box_task_scope.mjs +99 -0
  378. package/tests/branding.mjs +12 -0
  379. package/tests/cf_ruleset_preflight.mjs +142 -0
  380. package/tests/cf_tunnel.mjs +37 -0
  381. package/tests/claim_gate_rebase.mjs +98 -0
  382. package/tests/clawback_confirmed_backlog.mjs +103 -0
  383. package/tests/cli_lib_formatters.mjs +20 -0
  384. package/tests/confirm_attribution.mjs +30 -46
  385. package/tests/credit_grant.mjs +198 -10
  386. package/tests/criterion_authoring_gate.mjs +272 -0
  387. package/tests/criterion_progress.mjs +33 -1
  388. package/tests/dev_lib.mjs +253 -0
  389. package/tests/devbox_artifact_gate.mjs +75 -0
  390. package/tests/devbox_dmg_backdrop.mjs +137 -0
  391. package/tests/devbox_icons.mjs +304 -0
  392. package/tests/devbox_installer_brand.mjs +232 -0
  393. package/tests/devbox_key_register.mjs +234 -0
  394. package/tests/docs_openapi_route.mjs +110 -0
  395. package/tests/doctor_hooks.mjs +141 -0
  396. package/tests/extract_json.mjs +140 -0
  397. package/tests/fitness.mjs +89 -0
  398. package/tests/gen_freshness.mjs +96 -0
  399. package/tests/goal_advisory.mjs +251 -0
  400. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  401. package/tests/goal_routes.mjs +59 -4
  402. package/tests/goal_tier.mjs +12 -3
  403. package/tests/governance_catalog.mjs +129 -0
  404. package/tests/governance_claim_eligibility.mjs +262 -0
  405. package/tests/governance_config.mjs +187 -0
  406. package/tests/governance_ownership_scope.mjs +410 -0
  407. package/tests/governance_page_gates.mjs +324 -0
  408. package/tests/governance_parity.mjs +336 -0
  409. package/tests/governance_principals.mjs +232 -0
  410. package/tests/governance_protected_surfaces.mjs +208 -0
  411. package/tests/governance_require_permission.mjs +223 -0
  412. package/tests/governance_resolver.mjs +84 -0
  413. package/tests/governance_role_sync.mjs +231 -0
  414. package/tests/governance_seed.mjs +127 -0
  415. package/tests/governance_session_scope.mjs +434 -0
  416. package/tests/governance_vanilla_boot.mjs +203 -0
  417. package/tests/grade_attribution.mjs +7 -7
  418. package/tests/hall_audit.mjs +223 -0
  419. package/tests/hall_nav.mjs +191 -0
  420. package/tests/hall_palette.mjs +206 -0
  421. package/tests/hall_signout.mjs +124 -0
  422. package/tests/hook_user_agents.mjs +10 -0
  423. package/tests/idea_triage.mjs +162 -0
  424. package/tests/idp_device_exchange.mjs +93 -0
  425. package/tests/init.mjs +51 -0
  426. package/tests/instance_config.mjs +65 -0
  427. package/tests/instance_mismatch_guard.mjs +23 -6
  428. package/tests/instance_release_config.mjs +104 -0
  429. package/tests/linkify_refs.mjs +15 -2
  430. package/tests/migrate_root_resolution.mjs +124 -1
  431. package/tests/module_contributions.mjs +55 -0
  432. package/tests/module_loader.mjs +10 -5
  433. package/tests/module_scope_active_claims.mjs +94 -0
  434. package/tests/needs_rebase.mjs +92 -0
  435. package/tests/one_active_claim_per_session.mjs +76 -0
  436. package/tests/override_attribution.mjs +10 -8
  437. package/tests/override_request_ownership.mjs +8 -4
  438. package/tests/parse_cookie_decode.mjs +42 -0
  439. package/tests/pay_on_land.mjs +129 -0
  440. package/tests/platform_boot.mjs +100 -1
  441. package/tests/post_login_landing.mjs +42 -0
  442. package/tests/preflight_checks.mjs +72 -0
  443. package/tests/provision.mjs +132 -7
  444. package/tests/publish_branch_route.mjs +53 -0
  445. package/tests/publish_reconciler.mjs +187 -2
  446. package/tests/repo_info_binding.mjs +248 -0
  447. package/tests/reward_gate.mjs +11 -2
  448. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  449. package/tests/reward_mode_surfacing.mjs +168 -0
  450. package/tests/route_permission_check.mjs +301 -0
  451. package/tests/route_rank_check.mjs +254 -12
  452. package/tests/route_validation.mjs +30 -0
  453. package/tests/sequence.mjs +267 -0
  454. package/tests/session_digest.mjs +44 -0
  455. package/tests/session_index.mjs +18 -6
  456. package/tests/session_records.mjs +39 -1
  457. package/tests/session_reward_on_land.mjs +381 -0
  458. package/tests/session_reward_reconcile.mjs +229 -0
  459. package/tests/ship_api_client_pathspec.mjs +100 -0
  460. package/tests/ship_base_flag.mjs +93 -0
  461. package/tests/ship_ci_deploy.mjs +80 -3
  462. package/tests/ship_upstream_deletion.mjs +57 -0
  463. package/tests/smoke_target.mjs +233 -0
  464. package/tests/start_env_guard.mjs +87 -0
  465. package/tests/start_rebase_warning.mjs +103 -0
  466. package/tests/status_mirror_sync.mjs +122 -0
  467. package/tests/stop_refcheck.mjs +19 -1
  468. package/tests/task_goal_link.mjs +15 -6
  469. package/tests/upgrade.mjs +139 -0
  470. package/tests/web_admission_status.mjs +162 -0
  471. package/tests/write_validation_gaps.mjs +110 -0
@@ -0,0 +1,531 @@
1
+ // modules/platform-identity/platform-identity.js — the hub identity-provider
2
+ // domain (ADR 0141 §1/§2, task #1002106). Platform accounts + the SSO client
3
+ // registry + single-use authorization codes.
4
+ //
5
+ // THE ONE RULE (ADR 0016 / ADR 0141): this surface returns IDENTITY only —
6
+ // github_id, github_login, display_name, avatar_url — and NEVER a rank, credits,
7
+ // admission, or a session token. Authority is computed locally on each federated
8
+ // instance; the hub only answers "who is this GitHub user".
9
+ //
10
+ // Reaches core only through the doorway (src/module-api.js). The pure helpers
11
+ // (sha256hex, constantTimeEqualHex, clientSecretValid, redirectUriAllowed,
12
+ // newOpaqueCode) are exported for DB-free unit testing.
13
+ 'use strict';
14
+
15
+ const crypto = require('node:crypto');
16
+ const api = require('../../src/module-api');
17
+
18
+ const { pool: defaultPool } = api;
19
+
20
+ // Default authorization-code lifetime — deliberately short (~60s). A code is a
21
+ // bearer secret that travels through the browser; a tight TTL bounds the replay
22
+ // window even before the single-use guard fires.
23
+ const DEFAULT_CODE_TTL_MS = 60 * 1000;
24
+
25
+ // -------------------------------------------------------------------------
26
+ // Pure crypto helpers (no DB, no network — unit-tested directly)
27
+ // -------------------------------------------------------------------------
28
+
29
+ // SHA-256 hex of a string. Used to hash both the opaque authorization code and
30
+ // the client secret at rest. Both inputs are high-entropy random values, so a
31
+ // fast hash is appropriate (no password-dictionary surface); hashing-at-rest
32
+ // means a DB read leak never exposes a live code or secret.
33
+ function sha256hex(s) {
34
+ return crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
35
+ }
36
+
37
+ // Constant-time comparison of two hex digests. Length-checks first (digest
38
+ // lengths are fixed and non-secret), then crypto.timingSafeEqual so a timing
39
+ // side-channel can't reveal how many leading bytes of a guessed secret matched.
40
+ function constantTimeEqualHex(a, b) {
41
+ const ba = Buffer.from(String(a), 'utf8');
42
+ const bb = Buffer.from(String(b), 'utf8');
43
+ if (ba.length !== bb.length) return false;
44
+ return crypto.timingSafeEqual(ba, bb);
45
+ }
46
+
47
+ // A fresh opaque authorization code: 256 bits of CSPRNG, base64url (URL-safe so
48
+ // it rides a redirect query string without escaping).
49
+ function newOpaqueCode() {
50
+ return crypto.randomBytes(32).toString('base64url');
51
+ }
52
+
53
+ // A fixed decoy hash so the "unknown client" / "suspended client" paths perform
54
+ // the SAME hashing + constant-time compare as a real wrong-secret attempt. Without
55
+ // this, JS short-circuits before the compare for a missing client, leaking a timing
56
+ // oracle that distinguishes a registered client_id from an unregistered one — the
57
+ // "never distinguishes no-such-client from wrong-secret" guarantee (ADR 0141 §2).
58
+ // The decoy value is irrelevant; only that the work is identical on every path.
59
+ const DECOY_SECRET_HASH = sha256hex('platform-identity::absent-client::decoy');
60
+
61
+ // Does `presentedSecret` match the client's stored secret_hash? Constant-time, and
62
+ // constant-WORK: it always hashes the presented secret and runs one timingSafeEqual
63
+ // (against the real hash, or the decoy when the client is missing/secretless), then
64
+ // fails closed unless there was a real client secret to match. Callers may look the
65
+ // client up once and let this decide, with no client_id-enumeration timing oracle.
66
+ function clientSecretValid(client, presentedSecret) {
67
+ const hasRealSecret = !!(client && typeof client.secret_hash === 'string' && client.secret_hash);
68
+ const target = hasRealSecret ? client.secret_hash : DECOY_SECRET_HASH;
69
+ const match = constantTimeEqualHex(target, sha256hex(presentedSecret));
70
+ return hasRealSecret && match;
71
+ }
72
+
73
+ // Decide whether a RESOLVED hub session may complete the SSO handoff. resolveSession
74
+ // (the raw resolver the browser leg uses so it can redirect-to-sign-in rather than
75
+ // 401) does NOT apply the deny gates requireBuilder does (src/bongos/auth.js) — so
76
+ // this replicates them: a deactivated account (status 'inactive'), a dev-box-scoped
77
+ // token (source 'box', which lives OUTSIDE the ADR 0016 trust boundary), and any
78
+ // non-human service principal (system_role set) must NOT federate. Pure + exported
79
+ // so the gate is unit-tested directly. Returns { ok, reason }.
80
+ function sessionUsableForFederation(session) {
81
+ if (!session) return { ok: false, reason: 'no_session' };
82
+ if (session.status === 'inactive') return { ok: false, reason: 'inactive' };
83
+ if (session.source === 'box') return { ok: false, reason: 'box_scope' };
84
+ if (session.system_role) return { ok: false, reason: 'service_principal' };
85
+ return { ok: true, reason: null };
86
+ }
87
+
88
+ // Is `redirectUri` in this client's exact-match allow-list? The open-redirect
89
+ // guard (ADR 0141 §2 invariant #3): a code is only ever handed to a URI the
90
+ // client pre-registered, so a victim client's code can't be steered to an
91
+ // attacker callback.
92
+ function redirectUriAllowed(client, redirectUri) {
93
+ return !!client
94
+ && Array.isArray(client.redirect_uris)
95
+ && client.redirect_uris.includes(redirectUri);
96
+ }
97
+
98
+ // -------------------------------------------------------------------------
99
+ // Accounts
100
+ // -------------------------------------------------------------------------
101
+
102
+ // Upsert the platform account for a GitHub identity (keyed on the stable
103
+ // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
104
+ async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl }, { pool = defaultPool } = {}) {
105
+ const { rows } = await pool.query(
106
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
107
+ VALUES ($1, $2, $3, $4)
108
+ ON CONFLICT (github_id) DO UPDATE SET
109
+ github_login = EXCLUDED.github_login,
110
+ display_name = EXCLUDED.display_name,
111
+ avatar_url = EXCLUDED.avatar_url,
112
+ updated_at = now()
113
+ RETURNING id, github_id, github_login, display_name, avatar_url`,
114
+ [githubId, githubLogin, displayName ?? null, avatarUrl ?? null],
115
+ );
116
+ return rows[0];
117
+ }
118
+
119
+ // Resolve the platform account for a github_login (for invitations by handle). A
120
+ // login isn't a stable key (it can change), so this returns the most-recently-seen
121
+ // account for that login, or null. Used only to map an invite's handle → github_id.
122
+ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
123
+ const { rows } = await pool.query(
124
+ `SELECT github_id, github_login, display_name, avatar_url
125
+ FROM platform_identity_accounts WHERE lower(github_login) = lower($1)
126
+ ORDER BY updated_at DESC LIMIT 1`,
127
+ [String(githubLogin || '')],
128
+ );
129
+ return rows[0] || null;
130
+ }
131
+
132
+ async function getAccount(githubId, { pool = defaultPool } = {}) {
133
+ const { rows } = await pool.query(
134
+ `SELECT id, github_id, github_login, display_name, avatar_url, created_at, updated_at
135
+ FROM platform_identity_accounts WHERE github_id = $1`,
136
+ [githubId],
137
+ );
138
+ return rows[0] || null;
139
+ }
140
+
141
+ // -------------------------------------------------------------------------
142
+ // SSO client registry
143
+ // -------------------------------------------------------------------------
144
+
145
+ // Register (or rotate) a federated project as an SSO client. Provisioning
146
+ // (task #1002109) calls this with a freshly generated secret; the plaintext is
147
+ // hashed here and never stored. Upsert-on-client_id so re-provisioning rotates
148
+ // the secret + redirect_uris without a duplicate row.
149
+ async function registerClient({ clientId, secret, name, redirectUris, origin, ownerGithubId }, { pool = defaultPool } = {}) {
150
+ const secretHash = sha256hex(secret);
151
+ const uris = Array.isArray(redirectUris) ? redirectUris : [];
152
+ const { rows } = await pool.query(
153
+ `INSERT INTO platform_identity_sso_clients
154
+ (client_id, secret_hash, name, redirect_uris, origin, owner_github_id)
155
+ VALUES ($1, $2, $3, $4, $5, $6)
156
+ ON CONFLICT (client_id) DO UPDATE SET
157
+ secret_hash = EXCLUDED.secret_hash,
158
+ name = EXCLUDED.name,
159
+ redirect_uris = EXCLUDED.redirect_uris,
160
+ origin = EXCLUDED.origin,
161
+ owner_github_id = EXCLUDED.owner_github_id,
162
+ updated_at = now()
163
+ RETURNING id, client_id, name, redirect_uris, origin, owner_github_id, status`,
164
+ [clientId, secretHash, name, uris, origin ?? null, ownerGithubId ?? null],
165
+ );
166
+ // Auto-catalogue the project (unfeatured) so it appears in the Archon curation
167
+ // list the moment it federates (ADR 0141 §5). Best-effort — a catalog hiccup must
168
+ // never fail client registration. Only when an origin is known.
169
+ if (origin) {
170
+ try { await upsertProject({ origin, name, ownerGithubId }, { pool }); }
171
+ catch (err) { console.error('[platform-identity] auto-catalogue failed (non-fatal):', err.message); }
172
+ }
173
+ return rows[0];
174
+ }
175
+
176
+ // Fetch a client by its public id, including secret_hash (for the constant-time
177
+ // compare at /sso/token). Returns null when unknown.
178
+ async function getClient(clientId, { pool = defaultPool } = {}) {
179
+ const { rows } = await pool.query(
180
+ `SELECT client_id, secret_hash, name, redirect_uris, origin, owner_github_id, status
181
+ FROM platform_identity_sso_clients WHERE client_id = $1`,
182
+ [clientId],
183
+ );
184
+ return rows[0] || null;
185
+ }
186
+
187
+ // Resolve the ACTIVE client for a given instance origin. This is the authoritative
188
+ // mapping the CLI device-flow mint uses (ADR 0141 §1 security fix): the assertion's
189
+ // audience is derived from the USER-TYPED origin via this lookup, so a malicious
190
+ // instance manifest cannot steer the mint at a victim client_id. Exact-match on the
191
+ // normalized origin (trailing slashes trimmed). Returns null when no active client
192
+ // is registered for that origin (→ federation refuses, the correct default).
193
+ async function getClientByOrigin(origin, { pool = defaultPool } = {}) {
194
+ const norm = String(origin || '').replace(/\/+$/, '');
195
+ if (!norm) return null;
196
+ const { rows } = await pool.query(
197
+ `SELECT client_id, secret_hash, name, redirect_uris, origin, owner_github_id, status
198
+ FROM platform_identity_sso_clients
199
+ WHERE origin = $1 AND status = 'active'
200
+ ORDER BY id ASC LIMIT 1`,
201
+ [norm],
202
+ );
203
+ return rows[0] || null;
204
+ }
205
+
206
+ // -------------------------------------------------------------------------
207
+ // Projects catalog + Explore (ADR 0141 §5)
208
+ // -------------------------------------------------------------------------
209
+
210
+ // Upsert a catalog row (keyed by origin). Called when a client registers (so every
211
+ // federated project is catalogued, unfeatured) and by the Archon create/curate route.
212
+ // Never touches `featured` on update — that is Archon-only via setProjectFeatured.
213
+ async function upsertProject({ origin, name, tagline, artUrl, ownerGithubId }, { pool = defaultPool } = {}) {
214
+ const norm = String(origin || '').replace(/\/+$/, '');
215
+ if (!norm) throw new Error('project origin required');
216
+ const { rows } = await pool.query(
217
+ `INSERT INTO platform_identity_projects (origin, name, tagline, art_url, owner_github_id)
218
+ VALUES ($1, $2, $3, $4, $5)
219
+ ON CONFLICT (origin) DO UPDATE SET
220
+ name = EXCLUDED.name,
221
+ tagline = COALESCE(EXCLUDED.tagline, platform_identity_projects.tagline),
222
+ art_url = COALESCE(EXCLUDED.art_url, platform_identity_projects.art_url),
223
+ owner_github_id = COALESCE(EXCLUDED.owner_github_id, platform_identity_projects.owner_github_id),
224
+ updated_at = now()
225
+ RETURNING id, origin, name, tagline, art_url, owner_github_id, status, featured`,
226
+ [norm, name || norm, tagline ?? null, artUrl ?? null, ownerGithubId ?? null],
227
+ );
228
+ return rows[0];
229
+ }
230
+
231
+ async function getProject(id, { pool = defaultPool } = {}) {
232
+ const { rows } = await pool.query(
233
+ `SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured
234
+ FROM platform_identity_projects WHERE id = $1`,
235
+ [id],
236
+ );
237
+ return rows[0] || null;
238
+ }
239
+
240
+ // The PUBLIC Explore listing — featured + active projects only, public projection
241
+ // (no owner_github_id, no status). Newest first.
242
+ async function listFeaturedProjects({ pool = defaultPool } = {}) {
243
+ const { rows } = await pool.query(
244
+ `SELECT origin, name, tagline, art_url
245
+ FROM platform_identity_projects
246
+ WHERE featured = true AND status = 'active'
247
+ ORDER BY updated_at DESC`,
248
+ );
249
+ return rows;
250
+ }
251
+
252
+ // The Archon curation list — every active project + its featured flag.
253
+ async function listAllProjects({ pool = defaultPool } = {}) {
254
+ const { rows } = await pool.query(
255
+ `SELECT id, origin, name, tagline, art_url, owner_github_id, status, featured, updated_at
256
+ FROM platform_identity_projects
257
+ WHERE status = 'active'
258
+ ORDER BY featured DESC, updated_at DESC`,
259
+ );
260
+ return rows;
261
+ }
262
+
263
+ // Archon-only: flip the Explore feature flag on a catalog row.
264
+ async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
265
+ const { rows } = await pool.query(
266
+ `UPDATE platform_identity_projects SET featured = $2, updated_at = now()
267
+ WHERE id = $1 RETURNING id, origin, name, featured, status`,
268
+ [id, !!featured],
269
+ );
270
+ return rows[0] || null;
271
+ }
272
+
273
+ // -------------------------------------------------------------------------
274
+ // Project memberships (the cross-project directory — ADR 0141 §3)
275
+ // -------------------------------------------------------------------------
276
+
277
+ const MEMBERSHIP_ROLES = ['owner', 'builder', 'visitor', 'pending'];
278
+
279
+ // Upsert a builder's membership in a project (keyed by the reporting client + the
280
+ // builder's github_id). COARSE role only — never a rank. Refreshes last_active on
281
+ // every check-in. Returns the row.
282
+ async function upsertMembership({ clientId, githubId, role = 'builder' }, { pool = defaultPool } = {}) {
283
+ const r = MEMBERSHIP_ROLES.includes(role) ? role : 'builder';
284
+ const { rows } = await pool.query(
285
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
286
+ VALUES ($1, $2, $3)
287
+ ON CONFLICT (client_id, github_id) DO UPDATE SET
288
+ role = EXCLUDED.role,
289
+ last_active = now()
290
+ RETURNING id, client_id, github_id, role, joined_at, last_active`,
291
+ [clientId, githubId, r],
292
+ );
293
+ return rows[0];
294
+ }
295
+
296
+ // All memberships for one account, newest-active first, joined to the project's
297
+ // display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
298
+ async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
299
+ const { rows } = await pool.query(
300
+ `SELECT m.client_id, m.role, m.joined_at, m.last_active,
301
+ c.name AS project_name, c.origin AS project_origin, c.owner_github_id
302
+ FROM platform_identity_project_memberships m
303
+ LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
304
+ WHERE m.github_id = $1 AND m.role <> 'pending'
305
+ ORDER BY m.last_active DESC`,
306
+ [githubId],
307
+ );
308
+ return rows;
309
+ }
310
+
311
+ // -------------------------------------------------------------------------
312
+ // Invitations (ADR 0141 §5, task #1002115) — pre-seed a 'pending' membership
313
+ // -------------------------------------------------------------------------
314
+
315
+ // Invite a builder into a project they don't yet build on: pre-seed a 'pending'
316
+ // membership. Does NOT clobber an existing membership (ON CONFLICT DO NOTHING) — a
317
+ // current member is left as-is. Returns { invited:true, row } when a pending row was
318
+ // created, or { invited:false, alreadyMember:true } when a membership already existed.
319
+ async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
320
+ const { rows } = await pool.query(
321
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
322
+ VALUES ($1, $2, 'pending')
323
+ ON CONFLICT (client_id, github_id) DO NOTHING
324
+ RETURNING id, client_id, github_id, role`,
325
+ [clientId, githubId],
326
+ );
327
+ if (rows[0]) return { invited: true, row: rows[0] };
328
+ return { invited: false, alreadyMember: true };
329
+ }
330
+
331
+ // The pending invitations for one account (joined to the project display fields).
332
+ async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {}) {
333
+ const { rows } = await pool.query(
334
+ `SELECT m.client_id, m.joined_at AS invited_at,
335
+ c.name AS project_name, c.origin AS project_origin
336
+ FROM platform_identity_project_memberships m
337
+ LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
338
+ WHERE m.github_id = $1 AND m.role = 'pending'
339
+ ORDER BY m.joined_at DESC`,
340
+ [githubId],
341
+ );
342
+ return rows;
343
+ }
344
+
345
+ // Resolve a pending invite for (client, builder): accept → flip to 'builder' (the
346
+ // membership becomes real; the builder then signs into the project, whose own gate
347
+ // still applies + whose check-in keeps it fresh); decline → delete the pending row.
348
+ // Only ever touches a 'pending' row, so it can't downgrade or remove a real member.
349
+ async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPool } = {}) {
350
+ if (accept) {
351
+ const { rows } = await pool.query(
352
+ `UPDATE platform_identity_project_memberships SET role = 'builder', last_active = now()
353
+ WHERE client_id = $1 AND github_id = $2 AND role = 'pending'
354
+ RETURNING id`,
355
+ [clientId, githubId],
356
+ );
357
+ return { resolved: rows.length > 0, accepted: true };
358
+ }
359
+ const { rowCount } = await pool.query(
360
+ `DELETE FROM platform_identity_project_memberships
361
+ WHERE client_id = $1 AND github_id = $2 AND role = 'pending'`,
362
+ [clientId, githubId],
363
+ );
364
+ return { resolved: rowCount > 0, accepted: false };
365
+ }
366
+
367
+ // Self-service LEAVE (ADR 0141 §3): remove a builder's membership in a project. The
368
+ // directory reflects the leave immediately; if the builder still actively builds on
369
+ // that instance they re-appear on their next sign-in check-in (the instance holds
370
+ // local authority). Returns the number of rows removed (0 or 1).
371
+ async function removeMembership(clientId, githubId, { pool = defaultPool } = {}) {
372
+ const { rowCount } = await pool.query(
373
+ `DELETE FROM platform_identity_project_memberships WHERE client_id = $1 AND github_id = $2`,
374
+ [clientId, githubId],
375
+ );
376
+ return rowCount;
377
+ }
378
+
379
+ async function setClientStatus(clientId, status, { pool = defaultPool } = {}) {
380
+ const { rows } = await pool.query(
381
+ `UPDATE platform_identity_sso_clients SET status = $2, updated_at = now()
382
+ WHERE client_id = $1 RETURNING client_id, status`,
383
+ [clientId, status],
384
+ );
385
+ return rows[0] || null;
386
+ }
387
+
388
+ // -------------------------------------------------------------------------
389
+ // Cross-project builder activity (the scouting data plane — ADR 0141 §4)
390
+ // -------------------------------------------------------------------------
391
+
392
+ // Upsert one project's rollup for one builder (keyed by client_id + github_id).
393
+ // Descriptive analytics — never authority. Refreshes last_active on each report.
394
+ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [] }, { pool = defaultPool } = {}) {
395
+ const disc = Array.isArray(disciplines) ? disciplines : [];
396
+ const { rows } = await pool.query(
397
+ `INSERT INTO platform_identity_builder_project_activity
398
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, last_active, updated_at)
399
+ VALUES ($1, $2, $3, $4, $5, $6, $7, now(), now())
400
+ ON CONFLICT (client_id, github_id) DO UPDATE SET
401
+ credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
402
+ rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
403
+ last_active = now(), updated_at = now()
404
+ RETURNING id, client_id, github_id`,
405
+ [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, rank, disc],
406
+ );
407
+ return rows[0];
408
+ }
409
+
410
+ // The scouting rollup: one row PER BUILDER, aggregated across every project they
411
+ // build on. Joined to the account for display. Ranked by total credits then ships —
412
+ // a talent signal, NOT an authority. Powers the Archon-only scouting view (#1002114).
413
+ async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
414
+ const { rows } = await pool.query(
415
+ `SELECT a.github_id,
416
+ acc.github_login, acc.display_name, acc.avatar_url,
417
+ count(*)::int AS project_count,
418
+ sum(a.credits)::int AS total_credits,
419
+ sum(a.tasks_shipped)::int AS total_tasks_shipped,
420
+ sum(a.karma)::int AS total_karma,
421
+ max(a.last_active) AS last_active,
422
+ array_agg(DISTINCT d) FILTER (WHERE d IS NOT NULL) AS disciplines
423
+ FROM platform_identity_builder_project_activity a
424
+ LEFT JOIN platform_identity_accounts acc ON acc.github_id = a.github_id
425
+ LEFT JOIN LATERAL unnest(a.disciplines) AS d ON true
426
+ GROUP BY a.github_id, acc.github_login, acc.display_name, acc.avatar_url
427
+ ORDER BY total_credits DESC, total_tasks_shipped DESC
428
+ LIMIT $1`,
429
+ [limit],
430
+ );
431
+ return rows;
432
+ }
433
+
434
+ // -------------------------------------------------------------------------
435
+ // Authorization codes (single-use, short-lived, bound)
436
+ // -------------------------------------------------------------------------
437
+
438
+ // Mint a single-use authorization code bound to (client_id, github_id,
439
+ // redirect_uri), carrying an identity snapshot. Stores only the code's hash;
440
+ // returns the plaintext code (the only place it ever exists outside the browser).
441
+ async function mintAuthCode(
442
+ { clientId, githubId, githubLogin, displayName, avatarUrl, redirectUri, ttlMs = DEFAULT_CODE_TTL_MS },
443
+ { pool = defaultPool } = {},
444
+ ) {
445
+ const code = newOpaqueCode();
446
+ const codeHash = sha256hex(code);
447
+ const expiresAt = new Date(Date.now() + ttlMs);
448
+ await pool.query(
449
+ `INSERT INTO platform_identity_auth_codes
450
+ (code_hash, client_id, github_id, redirect_uri, github_login, display_name, avatar_url, expires_at)
451
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
452
+ [codeHash, clientId, githubId, redirectUri, githubLogin, displayName ?? null, avatarUrl ?? null, expiresAt],
453
+ );
454
+ return code;
455
+ }
456
+
457
+ // Atomically redeem a code for a given client. Single-winner by construction: the
458
+ // UPDATE ... WHERE consumed_at IS NULL ... RETURNING flips consumed_at in the same
459
+ // statement, so two concurrent redeems can never both succeed. Also enforces the
460
+ // client binding and the TTL. Returns the identity snapshot (+ the bound
461
+ // redirect_uri, for the caller's redirect_uri match) or null on any miss.
462
+ async function redeemAuthCode({ code, clientId }, { pool = defaultPool } = {}) {
463
+ const codeHash = sha256hex(code);
464
+ const { rows } = await pool.query(
465
+ `UPDATE platform_identity_auth_codes
466
+ SET consumed_at = now()
467
+ WHERE code_hash = $1
468
+ AND client_id = $2
469
+ AND consumed_at IS NULL
470
+ AND expires_at > now()
471
+ RETURNING github_id, github_login, display_name, avatar_url, redirect_uri`,
472
+ [codeHash, clientId],
473
+ );
474
+ return rows[0] || null;
475
+ }
476
+
477
+ // TTL sweep of expired / consumed codes — the bound on table growth (wired as a
478
+ // module poller, pollers/code-sweep.js, started only on the hub). Codes have a ~60s
479
+ // TTL, so a 5-minute grace after expiry/consumption is ample slack for clock skew
480
+ // while keeping the table to roughly the last few minutes of sign-in traffic even
481
+ // under a burst of mints. Cheap; safe to call on a timer.
482
+ async function sweepExpiredCodes({ pool = defaultPool } = {}) {
483
+ const { rowCount } = await pool.query(
484
+ `DELETE FROM platform_identity_auth_codes
485
+ WHERE expires_at < now() - interval '5 minutes'
486
+ OR (consumed_at IS NOT NULL AND consumed_at < now() - interval '5 minutes')`,
487
+ );
488
+ return rowCount;
489
+ }
490
+
491
+ module.exports = {
492
+ DEFAULT_CODE_TTL_MS,
493
+ // pure helpers
494
+ sha256hex,
495
+ constantTimeEqualHex,
496
+ newOpaqueCode,
497
+ clientSecretValid,
498
+ redirectUriAllowed,
499
+ sessionUsableForFederation,
500
+ // accounts
501
+ upsertAccount,
502
+ getAccount,
503
+ getAccountByLogin,
504
+ // clients
505
+ registerClient,
506
+ getClient,
507
+ getClientByOrigin,
508
+ setClientStatus,
509
+ // projects catalog + Explore
510
+ upsertProject,
511
+ getProject,
512
+ listFeaturedProjects,
513
+ listAllProjects,
514
+ setProjectFeatured,
515
+ // memberships
516
+ MEMBERSHIP_ROLES,
517
+ upsertMembership,
518
+ listMembershipsForAccount,
519
+ removeMembership,
520
+ // invitations
521
+ inviteToProject,
522
+ listPendingInvitesForAccount,
523
+ resolveInvite,
524
+ // cross-project activity + scouting
525
+ upsertActivity,
526
+ listScoutingRollup,
527
+ // codes
528
+ mintAuthCode,
529
+ redeemAuthCode,
530
+ sweepExpiredCodes,
531
+ };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ // modules/platform-identity/pollers/code-sweep.js — the loader poller entry that
4
+ // bounds platform_identity_auth_codes growth (ADR 0141 §2). Codes have a ~60s TTL;
5
+ // each /sso/authorize mints one row, and without a sweep the table would grow
6
+ // unbounded. The loader's startModulePollers requires THIS file and calls
7
+ // start(deps) at boot / stop() at shutdown for the ENABLED module only (the hub) —
8
+ // non-blocking + failure-isolated, so a sweep hiccup never touches sign-in.
9
+ //
10
+ // Mirrors the lifecycle publish-reconciler poller shape.
11
+
12
+ const pi = require('../platform-identity');
13
+
14
+ // Every 5 minutes — codes expire in ~60s and the sweep keeps a 5-minute grace, so
15
+ // this cadence keeps the table to roughly the last few minutes of traffic.
16
+ const SWEEP_INTERVAL_MS = 5 * 60 * 1000;
17
+
18
+ let timer = null;
19
+
20
+ async function sweepOnce(deps = {}) {
21
+ try {
22
+ const n = await pi.sweepExpiredCodes(deps.pool ? { pool: deps.pool } : {});
23
+ if (n > 0) console.log(`[platform-identity] code-sweep: pruned ${n} expired/consumed auth code(s)`);
24
+ } catch (err) {
25
+ // Best-effort: a sweep failure must never crash the process or block sign-in.
26
+ console.error('[platform-identity] code-sweep failed (non-fatal):', err && err.message);
27
+ }
28
+ }
29
+
30
+ module.exports = {
31
+ start: (deps = {}) => {
32
+ if (timer) return; // idempotent
33
+ // unref so the sweep timer never keeps the process alive on its own.
34
+ timer = setInterval(() => { sweepOnce(deps); }, SWEEP_INTERVAL_MS);
35
+ if (timer.unref) timer.unref();
36
+ },
37
+ stop: () => {
38
+ if (timer) { clearInterval(timer); timer = null; }
39
+ },
40
+ // exported for tests
41
+ sweepOnce,
42
+ SWEEP_INTERVAL_MS,
43
+ };