@bongos/core 1.23.22 → 1.24.1

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 (424) hide show
  1. package/.bongos-core.json +902 -407
  2. package/.claude/skills/blocker-review/SKILL.md +9 -1
  3. package/.claude/skills/blocker-solve/SKILL.md +1 -1
  4. package/.claude/skills/builder-claim/SKILL.md +1 -1
  5. package/.claude/skills/builder-reauth/SKILL.md +1 -1
  6. package/.claude/skills/builder-redteam/SKILL.md +1 -1
  7. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  8. package/.claude/skills/builder-setup/SKILL.md +1 -1
  9. package/.claude/skills/builder-ship/SKILL.md +1 -1
  10. package/.claude/skills/collab-review/SKILL.md +5 -2
  11. package/.claude/skills/design/SKILL.md +1 -1
  12. package/.claude/skills/goal-create/SKILL.md +2 -2
  13. package/.claude/skills/grade-recover/SKILL.md +1 -1
  14. package/.claude/skills/merge-mode/SKILL.md +1 -1
  15. package/.claude/skills/owner-review/SKILL.md +1 -1
  16. package/.claude/skills/planning-session/SKILL.md +11 -11
  17. package/.claude/skills/read-session-export/SKILL.md +1 -1
  18. package/.claude/skills/recall/SKILL.md +1 -1
  19. package/.claude/skills/scan-before-install/SKILL.md +1 -1
  20. package/.claude/skills/session-handoff/SKILL.md +1 -1
  21. package/.claude/skills/strand-fix/SKILL.md +13 -0
  22. package/.claude/skills/worktree-clean/SKILL.md +1 -1
  23. package/AGENTS.md +15 -0
  24. package/GEMINI.md +5 -0
  25. package/clients/bongos-client/README.md +1 -1
  26. package/clients/bongos-client/bongos-client.global.js +90 -0
  27. package/clients/bongos-client/index.cjs +90 -0
  28. package/clients/bongos-client/index.d.ts +149 -4
  29. package/clients/bongos-client/index.mjs +90 -0
  30. package/docs/adr/0054-session-token-reward-cost-plus.md +3 -1
  31. package/docs/adr/0055-server-mediated-branch-publish.md +1 -1
  32. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  33. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  34. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  35. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  36. package/docs/adr/README.md +5 -1
  37. package/docs/api/openapi.json +8555 -5876
  38. package/docs/api-reference.md +63 -10
  39. package/docs/architecture.md +37 -1
  40. package/docs/canonical-permissions.md +1 -1
  41. package/docs/copy-inventory.md +420 -337
  42. package/docs/copy-registry.json +1321 -500
  43. package/docs/file-map.md +1 -0
  44. package/docs/module-api-changelog.md +5 -0
  45. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  46. package/docs/onboarding/diagrams/README.md +1 -1
  47. package/docs/onboarding/drachmae-and-karma.md +15 -15
  48. package/docs/onboarding/primer.md +2 -2
  49. package/docs/page-inventory.json +55 -6
  50. package/docs/page-readings.json +1185 -1011
  51. package/docs/recipes/google-sign-in.md +60 -0
  52. package/docs/recipes/ops-gotchas.md +1 -1
  53. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  54. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  55. package/migrations/core_283_payout_approvals.sql +183 -0
  56. package/migrations/core_284_payout_gates.sql +179 -0
  57. package/migrations/core_285_task_needs.sql +74 -0
  58. package/modules/ai-studio/billing.js +67 -0
  59. package/modules/ai-studio/context.js +29 -0
  60. package/modules/ai-studio/db.js +172 -0
  61. package/modules/ai-studio/gemini.js +75 -0
  62. package/modules/ai-studio/generate.js +119 -0
  63. package/modules/ai-studio/keys.js +73 -0
  64. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  65. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  66. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  67. package/modules/ai-studio/openrouter.js +90 -0
  68. package/modules/ai-studio/payments.js +105 -0
  69. package/modules/ai-studio/pricing.js +92 -0
  70. package/modules/ai-studio/routes/ai-studio.js +235 -5
  71. package/modules/ai-studio/studio.js +121 -0
  72. package/modules/autonomy/fence.js +64 -1
  73. package/modules/builder-settings/builder-needs.js +27 -1
  74. package/modules/copy-desk/routes/copy-desk.js +5 -0
  75. package/modules/economy/achievements.js +22 -10
  76. package/modules/economy/cost-classify.js +9 -0
  77. package/modules/economy/cost-write-guard.js +52 -0
  78. package/modules/economy/cost.js +2 -1
  79. package/modules/economy/credits.js +18 -15
  80. package/modules/economy/goal-reward.js +161 -0
  81. package/modules/economy/reward.js +4 -0
  82. package/modules/economy/routes/cost.js +33 -7
  83. package/modules/government/catalog.js +6 -0
  84. package/modules/government/db.js +34 -0
  85. package/modules/government/docket.js +36 -0
  86. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  87. package/modules/government/pollers/board-expiry.js +108 -20
  88. package/modules/government/protected-surfaces.json +1 -0
  89. package/modules/government/routes/government.js +22 -0
  90. package/modules/government/routing.js +39 -5
  91. package/modules/government/waiting.js +74 -0
  92. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  93. package/modules/hall-ui/public/ai-studio.css +193 -0
  94. package/modules/hall-ui/public/ai-studio.html +98 -0
  95. package/modules/hall-ui/public/ai-studio.js +556 -0
  96. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  97. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  98. package/modules/hall-ui/public/blockers-lib.js +15 -1
  99. package/modules/hall-ui/public/blockers.html +8 -0
  100. package/modules/hall-ui/public/blockers.js +1 -1
  101. package/modules/hall-ui/public/claim-action.js +3 -0
  102. package/modules/hall-ui/public/collab.html +12 -0
  103. package/modules/hall-ui/public/craft-mode.js +4 -1
  104. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  105. package/modules/hall-ui/public/drachmae.html +1 -1
  106. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  107. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  108. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  109. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  110. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  111. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  112. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  113. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  114. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  115. package/modules/hall-ui/public/goal-inbox.js +1 -1
  116. package/modules/hall-ui/public/goals-render.js +4 -0
  117. package/modules/hall-ui/public/goals.html +8 -0
  118. package/modules/hall-ui/public/gov-living.js +6 -2
  119. package/modules/hall-ui/public/hall-ask.css +5 -0
  120. package/modules/hall-ui/public/hall-ask.js +39 -3
  121. package/modules/hall-ui/public/notes-panel.css +20 -0
  122. package/modules/hall-ui/public/notes-panel.js +146 -0
  123. package/modules/hall-ui/public/project-settings.html +1 -1
  124. package/modules/hall-ui/public/project-settings.js +7 -5
  125. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  126. package/modules/hall-ui/public/settings.html +15 -0
  127. package/modules/hall-ui/public/street-deck.js +12 -3
  128. package/modules/hall-ui/public/studio.css +90 -8
  129. package/modules/hall-ui/public/studio.html +39 -10
  130. package/modules/hall-ui/public/studio.js +148 -18
  131. package/modules/hall-ui/public/task.html +2 -0
  132. package/modules/hall-ui/public/task.js +4 -0
  133. package/modules/hall-ui/public/tweak-editor.js +12 -3
  134. package/modules/hall-ui/public/welcome-boot.css +42 -6
  135. package/modules/hall-ui/public/welcome-boot.js +20 -6
  136. package/modules/hall-ui/public/work.js +20 -0
  137. package/modules/hall-ui/records/ai-studio.md +36 -0
  138. package/modules/hall-ui/records/blockers-people.md +24 -0
  139. package/modules/lifecycle/db-claims.js +32 -4
  140. package/modules/lifecycle/db-goals.js +18 -5
  141. package/modules/lifecycle/db-tasks.js +15 -6
  142. package/modules/lifecycle/done-when.js +23 -1
  143. package/modules/lifecycle/lifecycle.js +5 -0
  144. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  145. package/modules/lifecycle/module.json +2 -1
  146. package/modules/lifecycle/notes.js +219 -0
  147. package/modules/lifecycle/page-notice.js +124 -0
  148. package/modules/lifecycle/page-tweak-artist.js +18 -1
  149. package/modules/lifecycle/publish-reconciler.js +67 -20
  150. package/modules/lifecycle/routes/claims.js +5 -2
  151. package/modules/lifecycle/routes/goals.js +3 -1
  152. package/modules/lifecycle/routes/notes.js +91 -0
  153. package/modules/lifecycle/routes/project-settings.js +43 -35
  154. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  155. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  156. package/modules/lifecycle/routes/tasks.js +12 -12
  157. package/modules/lifecycle/task-needs-gate.js +70 -0
  158. package/modules/market-watch/CLAUDE.md +15 -0
  159. package/modules/market-watch/db.js +340 -0
  160. package/modules/market-watch/key.js +58 -0
  161. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  162. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  163. package/modules/market-watch/module.json +26 -0
  164. package/modules/market-watch/pollers/hub-seed.js +54 -0
  165. package/modules/market-watch/pollers/weekly-run.js +100 -0
  166. package/modules/market-watch/research.js +350 -0
  167. package/modules/market-watch/run.js +245 -0
  168. package/modules/market-watch/seed/market-data.json +38 -0
  169. package/modules/market-watch/seed/partners-data.json +497 -0
  170. package/modules/market-watch/seed.js +252 -0
  171. package/modules/market-watch/store.js +230 -0
  172. package/modules/memory/routes/memory.js +14 -2
  173. package/modules/memory/routes/search.js +8 -2
  174. package/modules/platform-identity/account-logins.js +192 -0
  175. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  176. package/modules/platform-identity/platform-identity.js +5 -4
  177. package/modules/platform-identity/routes/my-projects.js +6 -0
  178. package/modules/platform-identity/routes/profile.js +18 -0
  179. package/modules/platform-identity/routes/public-profile.js +10 -1
  180. package/modules/platform-identity/routes/sso.js +26 -1
  181. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  182. package/modules/provisioning/credential-preflight.js +13 -3
  183. package/modules/provisioning/pollers/app-liveness.js +25 -5
  184. package/modules/provisioning/routes/provisioning.js +1 -1
  185. package/modules/provisioning/starter-bundles.js +6 -2
  186. package/modules/public-landing/public/account.html +10 -2
  187. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  188. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  189. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  190. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  191. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  192. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  193. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  194. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  195. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  196. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  197. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  198. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  199. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  200. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  201. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  202. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  203. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  204. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  205. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  206. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  207. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  208. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  209. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  210. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  211. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  212. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  213. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  214. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  215. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  216. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  217. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  218. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  219. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  220. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  221. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  222. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  223. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  224. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  225. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  226. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  227. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  228. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  229. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  230. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  231. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  232. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  233. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  234. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  235. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  236. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  237. package/modules/public-landing/public/community.html +1 -1
  238. package/modules/public-landing/public/connections.html +2 -2
  239. package/modules/public-landing/public/guild.html +1 -1
  240. package/modules/public-landing/public/invitations.html +2 -2
  241. package/modules/public-landing/public/projects.html +5 -5
  242. package/modules/raise/views/nda.html +2 -0
  243. package/modules/specialities/db.js +32 -1
  244. package/modules/specialities/routes/specialities.js +10 -6
  245. package/modules/status-ui/public/index.html +1 -1
  246. package/package-lock.json +2 -2
  247. package/package.json +1 -1
  248. package/release-notes.json +220 -0
  249. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  250. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  251. package/scripts/bongos/claim.js +22 -1
  252. package/scripts/bongos/db-backup-nightly.sh +29 -0
  253. package/scripts/bongos/deploy-divergence.js +7 -0
  254. package/scripts/bongos/doc-cli-guard.js +6 -0
  255. package/scripts/bongos/fitness.js +3 -2
  256. package/scripts/bongos/gen-diagrams.js +3 -3
  257. package/scripts/bongos/lib.sh +4 -5
  258. package/scripts/bongos/migration-namespace.js +13 -1
  259. package/scripts/bongos/plain-cards.js +4 -0
  260. package/scripts/bongos/provision-github-setup.js +3 -1
  261. package/scripts/bongos/provision-units.js +6 -1
  262. package/scripts/bongos/publish-manifest.js +3 -0
  263. package/scripts/bongos/release.js +2 -0
  264. package/scripts/bongos/run-unit-tests.js +268 -82
  265. package/scripts/bongos/seed-tasks.example.json +56 -0
  266. package/scripts/bongos/seed-tasks.js +222 -0
  267. package/scripts/bongos/seed-verify-criteria.js +1 -2
  268. package/scripts/bongos/ship-finish.js +2 -0
  269. package/scripts/bongos/ship-resume.js +2 -0
  270. package/scripts/bongos/strand-lease.js +250 -0
  271. package/scripts/bongos/strand-watch.js +26 -5
  272. package/scripts/bongos/version-literal-guard.js +2 -2
  273. package/scripts/discord/post.js +4 -3
  274. package/scripts/gds/seed-tasks.js +7 -0
  275. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  276. package/scripts/hall-preview/server.js +21 -0
  277. package/src/bongos/auth-config.js +29 -1
  278. package/src/bongos/auth-discord.js +3 -3
  279. package/src/bongos/auth-github.js +3 -6
  280. package/src/bongos/auth-google.js +217 -0
  281. package/src/bongos/device-approval.js +140 -0
  282. package/src/bongos/llm-pricing.js +17 -1
  283. package/src/bongos/metrics.js +48 -2
  284. package/src/bongos/middleware/rate-limit.js +30 -11
  285. package/src/bongos/module-scope-map.js +12 -1
  286. package/src/bongos/optimizer.js +15 -1
  287. package/src/bongos/payout-approvals.js +342 -0
  288. package/src/bongos/payout-gates.js +364 -0
  289. package/src/bongos/prelaunch-gate.js +7 -9
  290. package/src/bongos/prelaunch-switch.js +20 -0
  291. package/src/bongos/route-rank-check.js +3 -0
  292. package/src/bongos/routes/auth-google.js +246 -0
  293. package/src/bongos/routes/auth.js +38 -1
  294. package/src/bongos/routes/backup.js +33 -9
  295. package/src/bongos/routes/builders.js +57 -30
  296. package/src/bongos/routes/github.js +9 -0
  297. package/src/bongos/routes/me.js +18 -0
  298. package/src/bongos/routes/my-payouts.js +133 -0
  299. package/src/bongos/routes/payout-approvals.js +312 -0
  300. package/src/bongos/routes/payout-gates.js +179 -0
  301. package/src/bongos/routes.js +15 -1
  302. package/src/bongos/secret-box.js +18 -3
  303. package/src/bongos/serve-internal.js +47 -19
  304. package/src/bongos/sign-in-pages.js +138 -0
  305. package/src/bongos/task-needs.js +146 -0
  306. package/src/bongos-downloads.js +18 -1
  307. package/src/instance-config.js +28 -0
  308. package/src/module-api.js +14 -1
  309. package/tests/account_logins.mjs +326 -0
  310. package/tests/account_logins_db.mjs +161 -0
  311. package/tests/ai_studio_billing.mjs +253 -0
  312. package/tests/ai_studio_generate.mjs +282 -0
  313. package/tests/ai_studio_keys.mjs +159 -0
  314. package/tests/ai_studio_requests.mjs +319 -0
  315. package/tests/auth_backchannel_logout_route.mjs +201 -0
  316. package/tests/auto_satisfy_criteria.mjs +10 -1
  317. package/tests/autobongos_tweak_lane.mjs +144 -0
  318. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  319. package/tests/backfill_subagent_rewards.mjs +17 -1
  320. package/tests/backup_trigger_lock.mjs +119 -0
  321. package/tests/blockers_people.mjs +198 -0
  322. package/tests/bounded_rate_maps.mjs +184 -0
  323. package/tests/claimable_blocker_status.mjs +1 -1
  324. package/tests/claims_batch_routes.mjs +421 -0
  325. package/tests/config_read_cache.mjs +171 -0
  326. package/tests/copy_desk_page_editor.mjs +12 -0
  327. package/tests/cost_write_guard.mjs +200 -0
  328. package/tests/craft_achievements.mjs +33 -10
  329. package/tests/criterion_goal_attach.mjs +1 -1
  330. package/tests/db_backup_nightly.mjs +73 -1
  331. package/tests/deploy_divergence_line.mjs +7 -0
  332. package/tests/effective_visibility_predicate.mjs +12 -0
  333. package/tests/fitness.mjs +19 -0
  334. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  335. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  336. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  337. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  338. package/tests/github_one_trip.mjs +34 -0
  339. package/tests/github_repo_visibility.mjs +20 -0
  340. package/tests/goal_achievement.mjs +30 -1
  341. package/tests/goal_achievement_reward.mjs +205 -0
  342. package/tests/goal_sort_staleness.mjs +2 -1
  343. package/tests/goal_tier.mjs +11 -1
  344. package/tests/google_fonts_nonblocking.mjs +84 -0
  345. package/tests/google_sign_in.mjs +401 -0
  346. package/tests/government_board_expiry.mjs +94 -0
  347. package/tests/government_protected_surfaces.mjs +1 -1
  348. package/tests/government_routes.mjs +1 -1
  349. package/tests/hall_ai_studio.mjs +347 -0
  350. package/tests/hall_page_gate_map.mjs +3 -0
  351. package/tests/hall_studio_home.mjs +61 -2
  352. package/tests/home_attention_cards.mjs +1 -1
  353. package/tests/human_signal.mjs +141 -0
  354. package/tests/install_script_reader.mjs +51 -0
  355. package/tests/integration_fixture_ids.mjs +82 -0
  356. package/tests/invite_expiry_db.mjs +13 -1
  357. package/tests/lib_sh_resolution.mjs +0 -4
  358. package/tests/market_watch_data.mjs +244 -0
  359. package/tests/market_watch_data_db.mjs +308 -0
  360. package/tests/market_watch_module.mjs +114 -0
  361. package/tests/market_watch_run.mjs +503 -0
  362. package/tests/metrics.mjs +124 -0
  363. package/tests/migration_namespace.mjs +14 -0
  364. package/tests/module_api.mjs +2 -0
  365. package/tests/module_loader.mjs +1 -1
  366. package/tests/notes.mjs +182 -0
  367. package/tests/own_scope_guard.mjs +5 -0
  368. package/tests/page_tweak_marker_sql.mjs +76 -0
  369. package/tests/page_tweak_no_block.mjs +135 -0
  370. package/tests/page_tweak_notice.mjs +103 -0
  371. package/tests/payout_approvals.mjs +253 -0
  372. package/tests/payout_approvals_db.mjs +106 -0
  373. package/tests/payout_gates.mjs +322 -0
  374. package/tests/payout_gates_db.mjs +113 -0
  375. package/tests/planning_session_skill.mjs +13 -3
  376. package/tests/project_settings_page.mjs +29 -5
  377. package/tests/project_settings_spend_cap_route.mjs +235 -0
  378. package/tests/promotion_sweep.mjs +29 -1
  379. package/tests/provision.mjs +3 -2
  380. package/tests/provisioning_app_status.mjs +18 -0
  381. package/tests/provisioning_credential_preflight.mjs +53 -0
  382. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  383. package/tests/publish_reconciler_overlap.mjs +60 -0
  384. package/tests/rank_change_broadcast_port.mjs +151 -0
  385. package/tests/seed_tasks.mjs +176 -0
  386. package/tests/seed_verify_criteria.mjs +2 -6
  387. package/tests/site_classified_once.mjs +142 -0
  388. package/tests/speciality_db_shape.mjs +23 -0
  389. package/tests/speciality_session_skills.mjs +48 -2
  390. package/tests/strand_lease.mjs +249 -0
  391. package/tests/strand_watch.mjs +10 -0
  392. package/tests/task_criteria_routes.mjs +275 -0
  393. package/tests/task_criteria_unlink_db.mjs +123 -0
  394. package/tests/task_needs.mjs +233 -0
  395. package/tests/task_needs_db.mjs +110 -0
  396. package/tests/unit_runner_serial.mjs +266 -43
  397. package/tests/version_literals.mjs +5 -7
  398. package/tests/welcome_boot.mjs +16 -5
  399. package/tests/welcome_boot_browser.mjs +36 -4
  400. package/docs/recipes/bongos-cli-release.md +0 -66
  401. package/scripts/bongos/backfill-disciplines.js +0 -144
  402. package/scripts/bongos/backfill-session-earnings.js +0 -123
  403. package/scripts/bongos/backfill-session-types.js +0 -87
  404. package/scripts/bongos/build-bongos.js +0 -94
  405. package/scripts/bongos/cleanup-orphans.js +0 -74
  406. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  407. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  408. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  409. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  410. package/scripts/bongos/smoke-batch-release.sh +0 -316
  411. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  412. package/scripts/bongos/smoke-dependencies.sh +0 -143
  413. package/scripts/gds/backfill-disciplines.js +0 -7
  414. package/scripts/gds/backfill-session-earnings.js +0 -7
  415. package/scripts/gds/backfill-session-types.js +0 -7
  416. package/scripts/gds/cleanup-orphans.js +0 -7
  417. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  418. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  419. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  420. package/scripts/gds/smoke-batch-claim.sh +0 -8
  421. package/scripts/gds/smoke-batch-release.sh +0 -8
  422. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  423. package/scripts/gds/smoke-dependencies.sh +0 -8
  424. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -10,6 +10,7 @@
10
10
  'use strict';
11
11
 
12
12
  const ic = require('../instance-config');
13
+ const { readConfigFileCached } = require('../instance-config'); // destructured: knip credits only this form
13
14
  const { branding, userAgent } = require('../branding');
14
15
 
15
16
  const SCOPE = 'read:user';
@@ -62,6 +63,29 @@ function grantedScopeCoversRepos(granted) {
62
63
  // (the user just re-runs the setup skill). Acceptable for V1 — scaling up means
63
64
  // moving this to Redis or a tiny `device_flows` table.
64
65
  const deviceFlows = new Map();
66
+ // Task 1004964: expired flows used to be reaped only when a NEW flow started, so the
67
+ // map held its last burst forever on a quiet instance. Now it is also pruned on an
68
+ // unref'd five-minute timer (rare enough not to keep an idle server awake), and
69
+ // hard-capped: at the cap the oldest-inserted flow is evicted, the newest survive.
70
+ const DEVICE_FLOW_MAX = 50000;
71
+ const DEVICE_FLOW_PRUNE_MS = 5 * 60 * 1000;
72
+
73
+ function pruneDeviceFlows(now = Date.now()) {
74
+ for (const [k, v] of deviceFlows) {
75
+ if (v.expires_at < now) deviceFlows.delete(k);
76
+ }
77
+ }
78
+
79
+ // trackDeviceFlow(code, flow) — record a pending flow, evicting oldest-first at the cap.
80
+ // O(1): expiry is the timer's job, so a burst of starts never rescans the whole map.
81
+ function trackDeviceFlow(code, flow) {
82
+ deviceFlows.delete(code);
83
+ while (deviceFlows.size >= DEVICE_FLOW_MAX) deviceFlows.delete(deviceFlows.keys().next().value);
84
+ deviceFlows.set(code, flow);
85
+ }
86
+
87
+ const deviceFlowPruneTimer = setInterval(pruneDeviceFlows, DEVICE_FLOW_PRUNE_MS);
88
+ if (typeof deviceFlowPruneTimer.unref === 'function') deviceFlowPruneTimer.unref();
65
89
 
66
90
  function loadGithubCreds() {
67
91
  // Resolve the configured instance prefix first (CLOUDBONGOS_ on a fresh
@@ -77,7 +101,7 @@ function loadGithubCreds() {
77
101
  // across the configured config dir and the legacy ~/.config/otb dir.
78
102
  for (const name of ['gds-github.json', 'pms-github.json']) {
79
103
  try {
80
- const raw = ic.readConfigFileSync(name);
104
+ const raw = readConfigFileCached(name); // task 1004924: per-request path, cached (60 s TTL)
81
105
  if (raw) {
82
106
  const creds = JSON.parse(raw);
83
107
  if (creds.client_id && creds.client_secret) return creds;
@@ -143,6 +167,10 @@ module.exports = {
143
167
  SCOPE,
144
168
  authConfigured,
145
169
  deviceFlows,
170
+ trackDeviceFlow,
171
+ pruneDeviceFlows,
172
+ DEVICE_FLOW_MAX,
173
+ DEVICE_FLOW_PRUNE_MS,
146
174
  grantedScopeCoversRepos,
147
175
  idpConfigured,
148
176
  idpEnabled,
@@ -8,7 +8,7 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const ic = require('../instance-config');
11
+ const { readConfigFileCached } = require('../instance-config'); // destructured: knip credits only this form
12
12
 
13
13
  const DISCORD_SCOPE = 'identify guilds.join';
14
14
 
@@ -17,7 +17,7 @@ function loadDiscordCreds() {
17
17
  const clientSecret = process.env.DISCORD_CLIENT_SECRET;
18
18
  if (clientId && clientSecret) return { client_id: clientId, client_secret: clientSecret };
19
19
  try {
20
- const raw = ic.readConfigFileSync('discord-bot.json');
20
+ const raw = readConfigFileCached('discord-bot.json'); // task 1004924: per-request path, cached (60 s TTL)
21
21
  if (raw) {
22
22
  const creds = JSON.parse(raw);
23
23
  if (creds.client_id && creds.client_secret) {
@@ -43,7 +43,7 @@ function loadDiscordBotToken() {
43
43
  const envToken = process.env.DISCORD_BOT_TOKEN;
44
44
  if (envToken) return envToken;
45
45
  try {
46
- const raw = ic.readConfigFileSync('discord-bot.json');
46
+ const raw = readConfigFileCached('discord-bot.json'); // task 1004924: per-request path, cached (60 s TTL)
47
47
  if (raw) {
48
48
  const creds = JSON.parse(raw);
49
49
  if (creds.bot_token) return creds.bot_token;
@@ -25,7 +25,7 @@ const { pool } = require('./pool');
25
25
  const seams = require('../module-seams');
26
26
  const { branding, userAgent } = require('../branding');
27
27
  const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, safeSyncRankRole } = require('./auth-admission.js');
28
- const { SCOPE, deviceFlows, loadGithubCreds, scopeForWebFlow } = require('./auth-config.js');
28
+ const { SCOPE, deviceFlows, trackDeviceFlow, loadGithubCreds, scopeForWebFlow } = require('./auth-config.js');
29
29
  const log = require('./logger').logger.child({ src: 'auth-github' });
30
30
 
31
31
  function newSessionToken() {
@@ -121,15 +121,12 @@ async function startDeviceFlow() {
121
121
  throw new Error('GitHub device/code returned no device_code (unexpected response shape).');
122
122
  }
123
123
  // data: { device_code, user_code, verification_uri, expires_in, interval }
124
- deviceFlows.set(data.device_code, {
124
+ // Evicts oldest-first at the size cap; an unref'd timer reaps expired flows (auth-config.js).
125
+ trackDeviceFlow(data.device_code, {
125
126
  interval: data.interval || 5,
126
127
  expires_at: Date.now() + data.expires_in * 1000,
127
128
  last_polled_at: 0,
128
129
  });
129
- // Reap expired flows (cheap)
130
- for (const [k, v] of deviceFlows) {
131
- if (v.expires_at < Date.now()) deviceFlows.delete(k);
132
- }
133
130
  return {
134
131
  device_code: data.device_code,
135
132
  user_code: data.user_code,
@@ -0,0 +1,217 @@
1
+ // src/bongos/auth-google.js — signing in with Google (task 1004993, ADR 0396).
2
+ //
3
+ // WHY. A person with no GitHub account could not sign up at all. The owner chose
4
+ // Google as the one new way in (no email codes, no passwords, no other
5
+ // providers). This file holds the Google half of the browser sign-in: the
6
+ // authorize URL, the code exchange, checking the ID token Google returns, and
7
+ // turning that identity into a session on this instance.
8
+ //
9
+ // WHO THE PERSON IS. Google's own stable user id (`sub`) is the only thing that
10
+ // finds a person. The hub's account-logins table maps it to the person key
11
+ // (ADR 0393); an email is shown, never looked up, so owning an address that
12
+ // matches someone's account reaches nothing. The lookup lives on the hub, so this
13
+ // only works where the platform-identity module answers the `signInWithGoogle`
14
+ // port function — on every other instance Google sign-in is simply not offered.
15
+ //
16
+ // THE BUILDER ROW. Core's `builders.github_login` cannot be empty, and a person
17
+ // who signed up with Google has no GitHub login. They get a stand-in that can
18
+ // never be a GitHub login — `~` and the handle they choose on their account page
19
+ // (`standInLogin`) — so no admission rule, invite or GitHub call keyed on a login
20
+ // can ever reach a real GitHub user through them. Once GitHub is linked (task
21
+ // 1004995) the real login is used instead.
22
+ //
23
+ // THE ID TOKEN. It comes straight from Google's token endpoint over TLS in
24
+ // exchange for our client secret, so its signature is not re-checked (OpenID
25
+ // Connect Core's "ID Token Validation" allows TLS in its place); its issuer,
26
+ // audience, expiry and nonce are.
27
+ 'use strict';
28
+
29
+ const crypto = require('node:crypto');
30
+ const ic = require('../instance-config');
31
+ const { createSession, getBuilderByGithubId, upsertBuilder } = require('./db-kernel');
32
+ const { pool } = require('./pool');
33
+ const seams = require('../module-seams');
34
+ const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, safeSyncRankRole } = require('./auth-admission.js');
35
+ const log = require('./logger').logger.child({ src: 'auth-google' });
36
+
37
+ const GOOGLE_AUTHORIZE_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
38
+ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
39
+ const GOOGLE_ISSUERS = Object.freeze(['https://accounts.google.com', 'accounts.google.com']);
40
+ // Identity only: who they are, the address Google vouches for, a name and a picture.
41
+ const GOOGLE_SCOPE = 'openid email profile';
42
+ // Clock skew allowed when checking the token's expiry.
43
+ const EXPIRY_SKEW_MS = 60 * 1000;
44
+
45
+ // The Google sign-in app's credentials, from env (<PREFIX>_GOOGLE_CLIENT_ID /
46
+ // _GOOGLE_CLIENT_SECRET), or null. Env only — never a committed file.
47
+ function loadGoogleCreds() {
48
+ const clientId = ic.resolveEnv('GOOGLE_CLIENT_ID');
49
+ const clientSecret = ic.resolveEnv('GOOGLE_CLIENT_SECRET');
50
+ return clientId && clientSecret ? { client_id: clientId, client_secret: clientSecret } : null;
51
+ }
52
+
53
+ // The hub port that finds or creates a person by their Google id, or null.
54
+ function googleHub() {
55
+ try {
56
+ const hub = seams.resolveOptional('platform-identity.hub');
57
+ return hub && typeof hub.signInWithGoogle === 'function' ? hub : null;
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ // Is "Continue with Google" offered here? Both halves must be present: the
64
+ // owner's Google app, and the hub that keeps who signs in with it.
65
+ function googleConfigured() {
66
+ return !!loadGoogleCreds() && !!googleHub();
67
+ }
68
+
69
+ // The Google authorize URL for one sign-in. `nonce` is echoed back inside the ID
70
+ // token and checked, binding the token to this browser's sign-in.
71
+ function buildGoogleAuthorizeUrl({ state, nonce, redirectUri }) {
72
+ const creds = loadGoogleCreds();
73
+ if (!creds) throw new Error('Google sign-in is not configured');
74
+ const u = new URL(GOOGLE_AUTHORIZE_URL);
75
+ u.searchParams.set('client_id', creds.client_id);
76
+ u.searchParams.set('redirect_uri', redirectUri);
77
+ u.searchParams.set('response_type', 'code');
78
+ u.searchParams.set('scope', GOOGLE_SCOPE);
79
+ u.searchParams.set('state', state);
80
+ u.searchParams.set('nonce', nonce);
81
+ // Let a person with several Google accounts pick one, instead of Google
82
+ // silently using whichever is signed in (the GitHub "New account" lesson).
83
+ u.searchParams.set('prompt', 'select_account');
84
+ return u.toString();
85
+ }
86
+
87
+ // The claims inside an ID token (the middle, base64url part), or null.
88
+ function decodeIdToken(idToken) {
89
+ const parts = String(idToken || '').split('.');
90
+ if (parts.length !== 3) return null;
91
+ try {
92
+ return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ // Check an ID token's claims → the identity this sign-in carries
99
+ // { subject, email, emailVerified, displayName, avatarUrl }. Throws on anything
100
+ // that makes the token not ours, not Google's, stale, or not this sign-in's.
101
+ function verifyGoogleClaims(claims, { clientId, nonce, nowMs = Date.now() }) {
102
+ if (!claims || typeof claims !== 'object') throw new Error('Google returned no identity');
103
+ if (!GOOGLE_ISSUERS.includes(claims.iss)) throw new Error('the identity was not issued by Google');
104
+ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
105
+ if (!clientId || !aud.includes(clientId)) throw new Error('the identity was issued for another app');
106
+ if (aud.length > 1 && claims.azp !== clientId) throw new Error('the identity was issued for another app');
107
+ if (!Number.isFinite(claims.exp) || claims.exp * 1000 + EXPIRY_SKEW_MS < nowMs) throw new Error('the identity has expired');
108
+ if (!nonce || typeof claims.nonce !== 'string' || !timingSafeEqualStr(claims.nonce, nonce)) {
109
+ throw new Error('the identity does not belong to this sign-in');
110
+ }
111
+ const subject = typeof claims.sub === 'string' ? claims.sub : '';
112
+ if (!/^[\x21-\x7e]{1,255}$/.test(subject)) throw new Error('Google returned no account id');
113
+ return {
114
+ subject,
115
+ email: typeof claims.email === 'string' ? claims.email : null,
116
+ emailVerified: claims.email_verified === true || claims.email_verified === 'true',
117
+ displayName: typeof claims.name === 'string' ? claims.name.slice(0, 100) : null,
118
+ avatarUrl: typeof claims.picture === 'string' ? claims.picture : null,
119
+ };
120
+ }
121
+
122
+ function timingSafeEqualStr(a, b) {
123
+ const x = Buffer.from(String(a));
124
+ const y = Buffer.from(String(b));
125
+ return x.length === y.length && crypto.timingSafeEqual(x, y);
126
+ }
127
+
128
+ // Trade the code Google redirected back with for the ID token, and check it →
129
+ // the identity. `fetchImpl` is injectable so a test needs no network.
130
+ async function exchangeGoogleCode({ code, redirectUri, nonce, fetchImpl = fetch, nowMs = Date.now() }) {
131
+ const creds = loadGoogleCreds();
132
+ if (!creds) throw new Error('Google sign-in is not configured');
133
+ const res = await fetchImpl(GOOGLE_TOKEN_URL, {
134
+ method: 'POST',
135
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
136
+ body: new URLSearchParams({
137
+ code,
138
+ client_id: creds.client_id,
139
+ client_secret: creds.client_secret,
140
+ redirect_uri: redirectUri,
141
+ grant_type: 'authorization_code',
142
+ }).toString(),
143
+ });
144
+ const data = await res.json().catch(() => ({}));
145
+ if (!res.ok || data.error || !data.id_token) {
146
+ throw new Error(`Google token exchange: ${data.error_description || data.error || `HTTP ${res.status}`}`);
147
+ }
148
+ return verifyGoogleClaims(decodeIdToken(data.id_token), { clientId: creds.client_id, nonce, nowMs });
149
+ }
150
+
151
+ // The builder-row login for a person with no GitHub linked: `~` plus their
152
+ // handle. `~` is never in a GitHub login, so this can never name a real GitHub
153
+ // user (ADR 0396). The handle is the one they choose on their account page.
154
+ function standInLogin(handle, personKey) {
155
+ const h = String(handle || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 39);
156
+ return `~${h || `builder${String(personKey || '').replace(/\D/g, '')}`}`;
157
+ }
158
+
159
+ function signInError(code, extra = {}) {
160
+ const err = new Error(code.toLowerCase());
161
+ err.code = code;
162
+ Object.assign(err, extra);
163
+ return err;
164
+ }
165
+
166
+ // Turn a checked Google identity into a web session here → { token, builder,
167
+ // expiresAt, created }. The same gates as the GitHub web sign-in
168
+ // (completeWebFlow): a first-timer is admitted only under open enrollment (they
169
+ // have no GitHub login an Archon could have invited), and a switched-off
170
+ // builder gets no session.
171
+ async function completeGoogleFlow({ identity, userAgent }) {
172
+ const hub = googleHub();
173
+ if (!hub) throw signInError('GOOGLE_NOT_AVAILABLE');
174
+ const person = await hub.signInWithGoogle(identity);
175
+ const existing = await getBuilderByGithubId(person.personKey);
176
+ if (!existing && firstTimeAdmissionBlocked({ existing, approved: false, login: null })) {
177
+ throw signInError('NOT_APPROVED');
178
+ }
179
+ const login = person.githubLogin || standInLogin(person.handle, person.personKey);
180
+ let builder = await upsertBuilder({
181
+ githubId: person.personKey,
182
+ githubLogin: login,
183
+ displayName: identity.displayName || person.handle || login,
184
+ avatarUrl: identity.avatarUrl,
185
+ });
186
+ builder = await maybeSeatFirstAdmin(builder);
187
+ if (builder.status === 'inactive') throw signInError('BUILDER_INACTIVE', { login });
188
+ await safeSyncRankRole(builder);
189
+ const token = crypto.randomBytes(32).toString('hex');
190
+ const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
191
+ await createSession({ builderId: builder.id, token, source: 'web', userAgent: userAgent ?? null, expiresAt });
192
+ await seams.resolveOptional('onboarding')?.safeMarkStage(pool, builder.id, 'auth_complete', builder);
193
+ // The hub's own browser sign-in accepts the platform terms (ADR 0217 D3), as
194
+ // for GitHub. Best-effort: a failed stamp never costs the sign-in.
195
+ try {
196
+ if (typeof hub.recordHubSignin === 'function') {
197
+ await hub.recordHubSignin({
198
+ githubId: String(person.personKey),
199
+ githubLogin: person.githubLogin,
200
+ displayName: identity.displayName,
201
+ avatarUrl: identity.avatarUrl,
202
+ });
203
+ }
204
+ } catch (err) {
205
+ log.error({ err: err && err.message }, 'hub sign-in terms acceptance not recorded (google)');
206
+ }
207
+ return { token, builder, expiresAt, created: !!person.created };
208
+ }
209
+
210
+ module.exports = {
211
+ googleConfigured,
212
+ buildGoogleAuthorizeUrl,
213
+ verifyGoogleClaims,
214
+ exchangeGoogleCode,
215
+ standInLogin,
216
+ completeGoogleFlow,
217
+ };
@@ -0,0 +1,140 @@
1
+ // src/bongos/device-approval.js — command-line sign-in approved in the browser
2
+ // (task 1004993, ADR 0396).
3
+ //
4
+ // WHY. The CLI's sign-in has always been GitHub's own device flow: the terminal
5
+ // shows a code and the person types it at github.com. A person who signs in with
6
+ // Google has no GitHub account to type it into. So when this instance offers
7
+ // Google sign-in, the CLI's device flow is served HERE instead: the terminal gets
8
+ // a code and a link to this instance's own approval page, the person signs in
9
+ // there with GitHub or Google as they would in the browser, checks the code and
10
+ // presses Approve, and the terminal's next poll receives a CLI session.
11
+ //
12
+ // The CLI needs no change for this: /auth/device/start and /auth/device/poll keep
13
+ // their request and response shapes, and the CLI already opens whatever
14
+ // `verification_uri` it is handed.
15
+ //
16
+ // WHAT HOLDS IT SAFE. Approving is a cookie-session POST (the same rule as
17
+ // /auth/cli-token/issue: a bearer cannot mint a bearer), it binds the CLI session
18
+ // to whoever approved, and the page tells the person to check the code matches
19
+ // their terminal and to cancel a link someone sent them — the device-code
20
+ // phishing risk GitHub's own page carries, stated the same way. A code is
21
+ // single-use, lives FLOW_TTL_MS and is forgotten once polled to completion.
22
+ //
23
+ // In-memory, like GitHub's device flows in auth-config.js: a restart drops a
24
+ // pending sign-in and the person runs it again.
25
+ 'use strict';
26
+
27
+ const crypto = require('node:crypto');
28
+
29
+ const FLOW_TTL_MS = 10 * 60 * 1000;
30
+ const POLL_INTERVAL_S = 5;
31
+ const FLOWS_MAX = 10000;
32
+ // No vowels, so a code never spells a word; no look-alike letters.
33
+ const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
34
+
35
+ const flows = new Map(); // device_code → flow
36
+ const byUserCode = new Map(); // user_code → device_code
37
+
38
+ function prune(now) {
39
+ for (const [deviceCode, f] of flows) {
40
+ if (f.expiresAt <= now) forget(deviceCode);
41
+ }
42
+ }
43
+
44
+ function forget(deviceCode) {
45
+ const f = flows.get(deviceCode);
46
+ if (f) byUserCode.delete(f.userCode);
47
+ flows.delete(deviceCode);
48
+ }
49
+
50
+ function newUserCode() {
51
+ let out = '';
52
+ for (let i = 0; i < 8; i++) out += USER_CODE_ALPHABET[crypto.randomInt(USER_CODE_ALPHABET.length)];
53
+ return `${out.slice(0, 4)}-${out.slice(4)}`;
54
+ }
55
+
56
+ // A code as a person might type it (lower case, spaces, no dash) → canonical
57
+ // XXXX-XXXX, or null when it cannot be one.
58
+ function normalizeUserCode(raw) {
59
+ const letters = String(raw || '').toUpperCase().replace(/[^A-Z]/g, '');
60
+ if (letters.length !== 8 || [...letters].some((c) => !USER_CODE_ALPHABET.includes(c))) return null;
61
+ return `${letters.slice(0, 4)}-${letters.slice(4)}`;
62
+ }
63
+
64
+ // Start a sign-in for the terminal → the same shape GitHub's device/code answers,
65
+ // so the CLI reads it unchanged. `pageUrl` is this instance's approval page.
66
+ function startBrowserDeviceFlow({ pageUrl, now = Date.now() }) {
67
+ prune(now);
68
+ while (flows.size >= FLOWS_MAX) forget(flows.keys().next().value);
69
+ const deviceCode = crypto.randomBytes(32).toString('hex');
70
+ let userCode = newUserCode();
71
+ while (byUserCode.has(userCode)) userCode = newUserCode();
72
+ flows.set(deviceCode, { userCode, expiresAt: now + FLOW_TTL_MS, lastPolledAt: 0, result: null });
73
+ byUserCode.set(userCode, deviceCode);
74
+ const complete = `${pageUrl}?code=${encodeURIComponent(userCode)}`;
75
+ return {
76
+ device_code: deviceCode,
77
+ user_code: userCode,
78
+ // The CLI opens verification_uri, so it carries the code: the page opens
79
+ // with it filled in. verification_uri_complete is the standard name for it.
80
+ verification_uri: complete,
81
+ verification_uri_complete: complete,
82
+ expires_in: Math.round(FLOW_TTL_MS / 1000),
83
+ interval: POLL_INTERVAL_S,
84
+ };
85
+ }
86
+
87
+ // The pending flow a typed code names, or null (unknown, expired, already decided).
88
+ function pendingFlow(userCode, now = Date.now()) {
89
+ const code = normalizeUserCode(userCode);
90
+ const deviceCode = code && byUserCode.get(code);
91
+ const f = deviceCode && flows.get(deviceCode);
92
+ if (!f || f.expiresAt <= now || f.result) return null;
93
+ return { deviceCode, userCode: code };
94
+ }
95
+
96
+ // The signed-in person approved (or cancelled) the code. On approve, `mint` is
97
+ // called once to create the CLI session for THIS person and its result is held
98
+ // for the terminal's next poll. → true when a pending code was decided.
99
+ async function decideDeviceFlow({ userCode, approve, mint, now = Date.now() }) {
100
+ const p = pendingFlow(userCode, now);
101
+ if (!p) return false;
102
+ const f = flows.get(p.deviceCode);
103
+ // Decide before the await, so a double-submit cannot mint twice.
104
+ f.result = { state: 'deciding' };
105
+ try {
106
+ f.result = approve ? { state: 'ok', ...(await mint()) } : { state: 'denied' };
107
+ } catch (err) {
108
+ f.result = null; // undecided again: the person can retry
109
+ throw err;
110
+ }
111
+ return true;
112
+ }
113
+
114
+ // Is this a device code this file issued (rather than GitHub's)?
115
+ function isBrowserDeviceCode(deviceCode) {
116
+ return typeof deviceCode === 'string' && flows.has(deviceCode);
117
+ }
118
+
119
+ // The terminal's poll → { state: 'pending' | 'slow_down' | 'ok' | 'denied' | 'expired', … }.
120
+ // The same states pollDeviceFlow answers for GitHub's flow.
121
+ function pollBrowserDeviceFlow(deviceCode, now = Date.now()) {
122
+ const f = flows.get(deviceCode);
123
+ if (!f) return { state: 'expired' };
124
+ if (f.expiresAt <= now) { forget(deviceCode); return { state: 'expired' }; }
125
+ if (now - f.lastPolledAt < POLL_INTERVAL_S * 1000) return { state: 'slow_down', interval: POLL_INTERVAL_S };
126
+ f.lastPolledAt = now;
127
+ if (!f.result || f.result.state === 'deciding') return { state: 'pending' };
128
+ forget(deviceCode); // the token leaves exactly once
129
+ return f.result;
130
+ }
131
+
132
+ module.exports = {
133
+ FLOW_TTL_MS,
134
+ normalizeUserCode,
135
+ startBrowserDeviceFlow,
136
+ pendingFlow,
137
+ decideDeviceFlow,
138
+ isBrowserDeviceCode,
139
+ pollBrowserDeviceFlow,
140
+ };
@@ -283,4 +283,20 @@ function componentsFromModelUsage(modelUsage) {
283
283
  };
284
284
  }
285
285
 
286
- module.exports = { PRICES, FAMILY_CURRENT_MODEL, familyFor, priceUsage, canonicalizeModelId, priceModelUsage, componentsFromModelUsage };
286
+ // SERVER-TOOL FEES (task 1004937, ADR 0394 §3). The Messages API's web search is
287
+ // billed per search on top of tokens: $10 per 1,000 searches, i.e. $0.01 each.
288
+ // Web fetch has no fee of its own; the fetched page is billed as input tokens,
289
+ // which priceUsage already prices. This is the one place the search price is
290
+ // spelled. Source: Anthropic's published web-search price as cached in the
291
+ // claude-api reference (2026-10-06), NOT re-verified against the live pricing
292
+ // page; the first real Market Watch run (task 1004942) is the measurement.
293
+ const WEB_SEARCH_USD_PER_REQUEST = 0.01;
294
+
295
+ // priceWebSearches(n) — USD for n web searches (usage.server_tool_use.web_search_requests).
296
+ // A missing or garbage count prices as zero searches, never NaN.
297
+ function priceWebSearches(n) {
298
+ const k = Number(n);
299
+ return Number.isFinite(k) && k > 0 ? k * WEB_SEARCH_USD_PER_REQUEST : 0;
300
+ }
301
+
302
+ module.exports = { PRICES, FAMILY_CURRENT_MODEL, familyFor, priceUsage, canonicalizeModelId, priceModelUsage, componentsFromModelUsage, WEB_SEARCH_USD_PER_REQUEST, priceWebSearches };
@@ -51,9 +51,28 @@ function renderLabels(labels) {
51
51
  return '{' + keys.map((k) => `${k}="${escapeLabelValue(labels[k])}"`).join(',') + '}';
52
52
  }
53
53
 
54
+ // --- series cap (task 1004924) ---------------------------------------------
55
+ // Every distinct label set is a series held in memory forever, so a label fed by
56
+ // request input can grow the heap without bound. Past this many series a metric
57
+ // takes no NEW label set: it folds into an overflow series instead. A metric with
58
+ // a route label folds only the route to 'other' (method and status are bounded
59
+ // sets already, so the overflow is a handful of series); any other metric folds
60
+ // every label to 'other', one series.
61
+ const MAX_SERIES_PER_METRIC = 2000;
62
+ const OVERFLOW = 'other';
63
+
64
+ function capLabels(series, labels) {
65
+ if (series.size < MAX_SERIES_PER_METRIC || series.has(labelKey(labels))) return labels;
66
+ if ('route' in labels) return { ...labels, route: OVERFLOW };
67
+ const all = {};
68
+ for (const k of Object.keys(labels)) all[k] = OVERFLOW;
69
+ return all;
70
+ }
71
+
54
72
  function incCounter(name, help, labels = {}, by = 1) {
55
73
  let c = counters.get(name);
56
74
  if (!c) { c = { help, series: new Map() }; counters.set(name, c); }
75
+ labels = capLabels(c.series, labels);
57
76
  const key = labelKey(labels);
58
77
  const s = c.series.get(key);
59
78
  if (s) s.value += by;
@@ -63,6 +82,7 @@ function incCounter(name, help, labels = {}, by = 1) {
63
82
  function observeHistogram(name, help, labels, value) {
64
83
  let h = histograms.get(name);
65
84
  if (!h) { h = { help, series: new Map() }; histograms.set(name, h); }
85
+ labels = capLabels(h.series, labels);
66
86
  const key = labelKey(labels);
67
87
  let s = h.series.get(key);
68
88
  if (!s) { s = { labels, buckets: new Array(HIST_BUCKETS.length).fill(0), sum: 0, count: 0 }; h.series.set(key, s); }
@@ -74,8 +94,14 @@ function observeHistogram(name, help, labels, value) {
74
94
  }
75
95
 
76
96
  // --- event-loop lag sampler (unref'd so it never holds the process open) ---
97
+ // Sampled every 10 s, not every second (task 1004961): a 1 s timer woke an idle
98
+ // hosted instance once a second forever, which burns CPU credits on a small
99
+ // droplet. perf_hooks.monitorEventLoopDelay() was considered and rejected — its
100
+ // native sampler is itself a repeating libuv timer firing every `resolution` ms
101
+ // (default 10 ms), so it would wake the loop far MORE often, not less. The gauge
102
+ // keeps its name and meaning: the most recent sampled lag, in seconds.
77
103
  let _lagSeconds = 0;
78
- const _LAG_INTERVAL_MS = 1000;
104
+ const _LAG_INTERVAL_MS = 10_000;
79
105
  const _lagTimer = setInterval(() => {
80
106
  const start = process.hrtime.bigint();
81
107
  setImmediate(() => {
@@ -100,6 +126,26 @@ function normalizeRoute(p) {
100
126
  return '/' + parts.join('/');
101
127
  }
102
128
 
129
+ // routeLabel — the route label for one finished request (task 1004924). The
130
+ // literal path is caller-chosen (every scanner hit on /wp-admin/x.php or /.env
131
+ // is a new path), so it is never the label on its own:
132
+ // - a matched Express route labels by its PATTERN (mount path + route path),
133
+ // a fixed set only the code can grow;
134
+ // - a 404 with no matched route is the single label 'unmatched';
135
+ // - anything else unrouted (static files) keeps the normalized path, and the
136
+ // per-metric series cap above bounds even that.
137
+ const UNMATCHED = 'unmatched';
138
+ function routeLabel(req, status) {
139
+ const r = req.route;
140
+ if (r && r.path != null) {
141
+ const base = req.baseUrl && req.baseUrl !== '/' ? normalizeRoute(req.baseUrl) : '';
142
+ const p = typeof r.path === 'string' ? r.path : String(r.path);
143
+ return (base + p) || '/';
144
+ }
145
+ if (status === 404) return UNMATCHED;
146
+ return normalizeRoute(req.path);
147
+ }
148
+
103
149
  // --- HTTP middleware ------------------------------------------------------
104
150
  function httpMiddleware() {
105
151
  return (req, res, next) => {
@@ -107,9 +153,9 @@ function httpMiddleware() {
107
153
  if (req.path === '/metrics' || req.path === '/healthz') return next();
108
154
  const start = process.hrtime.bigint();
109
155
  res.on('finish', () => {
110
- const route = normalizeRoute(req.path);
111
156
  const method = req.method;
112
157
  const status = res.statusCode;
158
+ const route = routeLabel(req, status);
113
159
  incCounter('gds_http_requests_total', 'Total HTTP requests by method, normalized route, and status code.',
114
160
  { method, route, status: String(status) });
115
161
  const dur = Number(process.hrtime.bigint() - start) / 1e9;