@bongos/core 1.23.22 → 1.24.2

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 (435) hide show
  1. package/.bongos-core.json +933 -413
  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 +96 -0
  27. package/clients/bongos-client/index.cjs +96 -0
  28. package/clients/bongos-client/index.d.ts +155 -4
  29. package/clients/bongos-client/index.mjs +96 -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/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +12 -1
  33. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  34. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  35. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  36. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  37. package/docs/adr/README.md +6 -2
  38. package/docs/api/openapi.json +8651 -5833
  39. package/docs/api-reference.md +66 -10
  40. package/docs/architecture.md +37 -1
  41. package/docs/canonical-permissions.md +1 -1
  42. package/docs/copy-inventory.md +427 -339
  43. package/docs/copy-registry.json +1376 -500
  44. package/docs/file-map.md +1 -0
  45. package/docs/module-api-changelog.md +7 -0
  46. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  47. package/docs/onboarding/diagrams/README.md +1 -1
  48. package/docs/onboarding/drachmae-and-karma.md +15 -15
  49. package/docs/onboarding/primer.md +2 -2
  50. package/docs/page-inventory.json +57 -7
  51. package/docs/page-readings.json +1210 -1027
  52. package/docs/recipes/google-sign-in.md +60 -0
  53. package/docs/recipes/ops-gotchas.md +1 -1
  54. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  55. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  56. package/migrations/core_283_payout_approvals.sql +183 -0
  57. package/migrations/core_284_payout_gates.sql +179 -0
  58. package/migrations/core_285_task_needs.sql +74 -0
  59. package/modules/ai-studio/billing.js +67 -0
  60. package/modules/ai-studio/context.js +29 -0
  61. package/modules/ai-studio/db.js +172 -0
  62. package/modules/ai-studio/gemini.js +75 -0
  63. package/modules/ai-studio/generate.js +119 -0
  64. package/modules/ai-studio/keys.js +73 -0
  65. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  66. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  67. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  68. package/modules/ai-studio/openrouter.js +90 -0
  69. package/modules/ai-studio/payments.js +105 -0
  70. package/modules/ai-studio/pricing.js +92 -0
  71. package/modules/ai-studio/routes/ai-studio.js +235 -5
  72. package/modules/ai-studio/studio.js +121 -0
  73. package/modules/autonomy/fence.js +64 -1
  74. package/modules/builder-settings/builder-needs.js +27 -1
  75. package/modules/copy-desk/routes/copy-desk.js +5 -0
  76. package/modules/economy/achievements.js +22 -10
  77. package/modules/economy/cost-classify.js +9 -0
  78. package/modules/economy/cost-write-guard.js +52 -0
  79. package/modules/economy/cost.js +2 -1
  80. package/modules/economy/credits.js +18 -15
  81. package/modules/economy/goal-reward.js +161 -0
  82. package/modules/economy/reward.js +4 -0
  83. package/modules/economy/routes/cost.js +33 -7
  84. package/modules/government/access-routes.js +70 -0
  85. package/modules/government/access.js +88 -0
  86. package/modules/government/asks.js +5 -2
  87. package/modules/government/catalog.js +6 -0
  88. package/modules/government/db.js +72 -0
  89. package/modules/government/docket.js +48 -2
  90. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  91. package/modules/government/migrations/government_036_access_holders.sql +33 -0
  92. package/modules/government/pollers/board-expiry.js +108 -20
  93. package/modules/government/protected-surfaces.json +1 -0
  94. package/modules/government/routes/government.js +31 -0
  95. package/modules/government/routing.js +49 -7
  96. package/modules/government/waiting.js +78 -0
  97. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  98. package/modules/hall-ui/public/ai-studio.css +193 -0
  99. package/modules/hall-ui/public/ai-studio.html +98 -0
  100. package/modules/hall-ui/public/ai-studio.js +556 -0
  101. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  102. package/modules/hall-ui/public/blockers-by-person.js +158 -0
  103. package/modules/hall-ui/public/blockers-lib.js +15 -1
  104. package/modules/hall-ui/public/blockers.html +8 -0
  105. package/modules/hall-ui/public/blockers.js +1 -1
  106. package/modules/hall-ui/public/claim-action.js +3 -0
  107. package/modules/hall-ui/public/collab.html +12 -0
  108. package/modules/hall-ui/public/craft-mode.js +4 -1
  109. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  110. package/modules/hall-ui/public/drachmae.html +1 -1
  111. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  112. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  113. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  114. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  115. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  120. package/modules/hall-ui/public/goal-inbox.js +1 -1
  121. package/modules/hall-ui/public/goals-render.js +4 -0
  122. package/modules/hall-ui/public/goals.html +8 -0
  123. package/modules/hall-ui/public/gov-living.js +6 -2
  124. package/modules/hall-ui/public/government-access.js +108 -0
  125. package/modules/hall-ui/public/government.html +21 -0
  126. package/modules/hall-ui/public/hall-ask.css +5 -0
  127. package/modules/hall-ui/public/hall-ask.js +39 -3
  128. package/modules/hall-ui/public/notes-panel.css +20 -0
  129. package/modules/hall-ui/public/notes-panel.js +146 -0
  130. package/modules/hall-ui/public/project-settings.html +1 -1
  131. package/modules/hall-ui/public/project-settings.js +7 -5
  132. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  133. package/modules/hall-ui/public/settings.html +15 -0
  134. package/modules/hall-ui/public/street-deck.js +12 -3
  135. package/modules/hall-ui/public/studio.css +90 -8
  136. package/modules/hall-ui/public/studio.html +39 -10
  137. package/modules/hall-ui/public/studio.js +148 -18
  138. package/modules/hall-ui/public/task.html +2 -0
  139. package/modules/hall-ui/public/task.js +4 -0
  140. package/modules/hall-ui/public/tweak-editor.js +12 -3
  141. package/modules/hall-ui/public/welcome-boot.css +42 -6
  142. package/modules/hall-ui/public/welcome-boot.js +20 -6
  143. package/modules/hall-ui/public/work.js +20 -0
  144. package/modules/hall-ui/records/ai-studio.md +36 -0
  145. package/modules/hall-ui/records/blockers-people.md +38 -0
  146. package/modules/lifecycle/db-claims.js +32 -4
  147. package/modules/lifecycle/db-goals.js +18 -5
  148. package/modules/lifecycle/db-tasks.js +15 -6
  149. package/modules/lifecycle/done-when.js +23 -1
  150. package/modules/lifecycle/lifecycle.js +5 -0
  151. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  152. package/modules/lifecycle/module.json +2 -1
  153. package/modules/lifecycle/notes.js +219 -0
  154. package/modules/lifecycle/page-notice.js +124 -0
  155. package/modules/lifecycle/page-tweak-artist.js +18 -1
  156. package/modules/lifecycle/publish-reconciler.js +67 -20
  157. package/modules/lifecycle/routes/claims.js +5 -2
  158. package/modules/lifecycle/routes/goals.js +3 -1
  159. package/modules/lifecycle/routes/notes.js +91 -0
  160. package/modules/lifecycle/routes/project-settings.js +43 -35
  161. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  162. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  163. package/modules/lifecycle/routes/tasks.js +12 -12
  164. package/modules/lifecycle/task-needs-gate.js +70 -0
  165. package/modules/market-watch/CLAUDE.md +15 -0
  166. package/modules/market-watch/db.js +340 -0
  167. package/modules/market-watch/key.js +58 -0
  168. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  169. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  170. package/modules/market-watch/module.json +26 -0
  171. package/modules/market-watch/pollers/hub-seed.js +54 -0
  172. package/modules/market-watch/pollers/weekly-run.js +100 -0
  173. package/modules/market-watch/research.js +350 -0
  174. package/modules/market-watch/run.js +245 -0
  175. package/modules/market-watch/seed/market-data.json +38 -0
  176. package/modules/market-watch/seed/partners-data.json +497 -0
  177. package/modules/market-watch/seed.js +252 -0
  178. package/modules/market-watch/store.js +230 -0
  179. package/modules/memory/routes/memory.js +14 -2
  180. package/modules/memory/routes/search.js +8 -2
  181. package/modules/platform-identity/account-logins.js +192 -0
  182. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  183. package/modules/platform-identity/platform-identity.js +5 -4
  184. package/modules/platform-identity/routes/my-projects.js +6 -0
  185. package/modules/platform-identity/routes/profile.js +18 -0
  186. package/modules/platform-identity/routes/public-profile.js +10 -1
  187. package/modules/platform-identity/routes/sso.js +26 -1
  188. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  189. package/modules/provisioning/credential-preflight.js +13 -3
  190. package/modules/provisioning/pollers/app-liveness.js +25 -5
  191. package/modules/provisioning/routes/provisioning.js +1 -1
  192. package/modules/provisioning/starter-bundles.js +6 -2
  193. package/modules/public-landing/public/account.html +10 -2
  194. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  195. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  196. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  197. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  198. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  199. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  200. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  201. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  202. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  203. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  205. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  209. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  210. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  213. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  214. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  221. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  222. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  223. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  224. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  225. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  226. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  227. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  228. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  229. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  230. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  233. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  244. package/modules/public-landing/public/community.html +1 -1
  245. package/modules/public-landing/public/connections.html +2 -2
  246. package/modules/public-landing/public/guild.html +1 -1
  247. package/modules/public-landing/public/invitations.html +2 -2
  248. package/modules/public-landing/public/projects.html +5 -5
  249. package/modules/raise/views/nda.html +2 -0
  250. package/modules/specialities/db.js +32 -1
  251. package/modules/specialities/routes/specialities.js +10 -6
  252. package/modules/status-ui/public/index.html +1 -1
  253. package/package-lock.json +2 -2
  254. package/package.json +1 -1
  255. package/release-notes.json +230 -0
  256. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  257. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  258. package/scripts/bongos/claim.js +22 -1
  259. package/scripts/bongos/db-backup-nightly.sh +29 -0
  260. package/scripts/bongos/deploy-divergence.js +7 -0
  261. package/scripts/bongos/doc-cli-guard.js +6 -0
  262. package/scripts/bongos/fitness.js +3 -2
  263. package/scripts/bongos/gen-diagrams.js +3 -3
  264. package/scripts/bongos/government-walk.js +3 -0
  265. package/scripts/bongos/lib.sh +4 -5
  266. package/scripts/bongos/migration-namespace.js +13 -1
  267. package/scripts/bongos/plain-cards.js +4 -0
  268. package/scripts/bongos/provision-github-setup.js +3 -1
  269. package/scripts/bongos/provision-units.js +6 -1
  270. package/scripts/bongos/publish-manifest.js +3 -0
  271. package/scripts/bongos/release.js +2 -0
  272. package/scripts/bongos/run-unit-tests.js +268 -82
  273. package/scripts/bongos/seed-tasks.example.json +56 -0
  274. package/scripts/bongos/seed-tasks.js +222 -0
  275. package/scripts/bongos/seed-verify-criteria.js +1 -2
  276. package/scripts/bongos/ship-finish.js +2 -0
  277. package/scripts/bongos/ship-resume.js +2 -0
  278. package/scripts/bongos/strand-lease.js +250 -0
  279. package/scripts/bongos/strand-watch.js +26 -5
  280. package/scripts/bongos/version-literal-guard.js +2 -2
  281. package/scripts/discord/post.js +4 -3
  282. package/scripts/gds/seed-tasks.js +7 -0
  283. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  284. package/scripts/hall-preview/server.js +21 -0
  285. package/src/bongos/auth-config.js +29 -1
  286. package/src/bongos/auth-discord.js +3 -3
  287. package/src/bongos/auth-github.js +3 -6
  288. package/src/bongos/auth-google.js +217 -0
  289. package/src/bongos/device-approval.js +140 -0
  290. package/src/bongos/llm-pricing.js +17 -1
  291. package/src/bongos/metrics.js +48 -2
  292. package/src/bongos/middleware/rate-limit.js +30 -11
  293. package/src/bongos/module-scope-map.js +12 -1
  294. package/src/bongos/optimizer.js +15 -1
  295. package/src/bongos/payout-approvals.js +342 -0
  296. package/src/bongos/payout-gates.js +364 -0
  297. package/src/bongos/prelaunch-gate.js +7 -9
  298. package/src/bongos/prelaunch-switch.js +20 -0
  299. package/src/bongos/route-rank-check.js +7 -0
  300. package/src/bongos/routes/auth-google.js +246 -0
  301. package/src/bongos/routes/auth.js +38 -1
  302. package/src/bongos/routes/backup.js +33 -9
  303. package/src/bongos/routes/builders.js +57 -30
  304. package/src/bongos/routes/github.js +9 -0
  305. package/src/bongos/routes/me.js +18 -0
  306. package/src/bongos/routes/my-payouts.js +133 -0
  307. package/src/bongos/routes/payout-approvals.js +312 -0
  308. package/src/bongos/routes/payout-gates.js +179 -0
  309. package/src/bongos/routes.js +15 -1
  310. package/src/bongos/secret-box.js +18 -3
  311. package/src/bongos/serve-internal.js +47 -19
  312. package/src/bongos/sign-in-pages.js +138 -0
  313. package/src/bongos/task-needs.js +146 -0
  314. package/src/bongos-downloads.js +18 -1
  315. package/src/instance-config.js +28 -0
  316. package/src/module-api.js +14 -1
  317. package/tests/access_holders.mjs +214 -0
  318. package/tests/account_logins.mjs +326 -0
  319. package/tests/account_logins_db.mjs +161 -0
  320. package/tests/ai_studio_billing.mjs +253 -0
  321. package/tests/ai_studio_generate.mjs +282 -0
  322. package/tests/ai_studio_keys.mjs +159 -0
  323. package/tests/ai_studio_requests.mjs +319 -0
  324. package/tests/auth_backchannel_logout_route.mjs +201 -0
  325. package/tests/auto_satisfy_criteria.mjs +10 -1
  326. package/tests/autobongos_tweak_lane.mjs +144 -0
  327. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  328. package/tests/backfill_subagent_rewards.mjs +17 -1
  329. package/tests/backup_trigger_lock.mjs +119 -0
  330. package/tests/blockers_people.mjs +198 -0
  331. package/tests/bounded_rate_maps.mjs +184 -0
  332. package/tests/claimable_blocker_status.mjs +1 -1
  333. package/tests/claims_batch_routes.mjs +421 -0
  334. package/tests/config_read_cache.mjs +171 -0
  335. package/tests/copy_desk_page_editor.mjs +12 -0
  336. package/tests/cost_write_guard.mjs +200 -0
  337. package/tests/craft_achievements.mjs +33 -10
  338. package/tests/criterion_goal_attach.mjs +1 -1
  339. package/tests/db_backup_nightly.mjs +73 -1
  340. package/tests/deploy_divergence_line.mjs +7 -0
  341. package/tests/effective_visibility_predicate.mjs +12 -0
  342. package/tests/fitness.mjs +19 -0
  343. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  344. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  345. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  346. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  347. package/tests/github_one_trip.mjs +34 -0
  348. package/tests/github_repo_visibility.mjs +20 -0
  349. package/tests/goal_achievement.mjs +30 -1
  350. package/tests/goal_achievement_reward.mjs +205 -0
  351. package/tests/goal_sort_staleness.mjs +2 -1
  352. package/tests/goal_tier.mjs +11 -1
  353. package/tests/google_fonts_nonblocking.mjs +84 -0
  354. package/tests/google_sign_in.mjs +401 -0
  355. package/tests/government_board_expiry.mjs +94 -0
  356. package/tests/government_board_membership.mjs +4 -1
  357. package/tests/government_protected_surfaces.mjs +1 -1
  358. package/tests/government_routes.mjs +1 -1
  359. package/tests/government_seed.mjs +1 -0
  360. package/tests/hall_ai_studio.mjs +347 -0
  361. package/tests/hall_page_gate_map.mjs +3 -0
  362. package/tests/hall_studio_home.mjs +61 -2
  363. package/tests/home_attention_cards.mjs +1 -1
  364. package/tests/human_signal.mjs +141 -0
  365. package/tests/install_script_reader.mjs +51 -0
  366. package/tests/integration_fixture_ids.mjs +82 -0
  367. package/tests/invite_expiry_db.mjs +13 -1
  368. package/tests/lib_sh_resolution.mjs +0 -4
  369. package/tests/market_watch_data.mjs +244 -0
  370. package/tests/market_watch_data_db.mjs +308 -0
  371. package/tests/market_watch_module.mjs +114 -0
  372. package/tests/market_watch_run.mjs +503 -0
  373. package/tests/metrics.mjs +124 -0
  374. package/tests/migration_namespace.mjs +14 -0
  375. package/tests/module_api.mjs +2 -0
  376. package/tests/module_loader.mjs +1 -1
  377. package/tests/notes.mjs +182 -0
  378. package/tests/own_scope_guard.mjs +5 -0
  379. package/tests/page_tweak_marker_sql.mjs +76 -0
  380. package/tests/page_tweak_no_block.mjs +135 -0
  381. package/tests/page_tweak_notice.mjs +103 -0
  382. package/tests/payout_approvals.mjs +253 -0
  383. package/tests/payout_approvals_db.mjs +106 -0
  384. package/tests/payout_gates.mjs +322 -0
  385. package/tests/payout_gates_db.mjs +113 -0
  386. package/tests/planning_session_skill.mjs +13 -3
  387. package/tests/project_settings_page.mjs +29 -5
  388. package/tests/project_settings_spend_cap_route.mjs +235 -0
  389. package/tests/promotion_sweep.mjs +29 -1
  390. package/tests/provision.mjs +3 -2
  391. package/tests/provisioning_app_status.mjs +18 -0
  392. package/tests/provisioning_credential_preflight.mjs +53 -0
  393. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  394. package/tests/publish_reconciler_overlap.mjs +60 -0
  395. package/tests/rank_change_broadcast_port.mjs +151 -0
  396. package/tests/seed_tasks.mjs +176 -0
  397. package/tests/seed_verify_criteria.mjs +2 -6
  398. package/tests/site_classified_once.mjs +142 -0
  399. package/tests/speciality_db_shape.mjs +23 -0
  400. package/tests/speciality_session_skills.mjs +48 -2
  401. package/tests/strand_lease.mjs +249 -0
  402. package/tests/strand_watch.mjs +10 -0
  403. package/tests/task_criteria_routes.mjs +275 -0
  404. package/tests/task_criteria_unlink_db.mjs +123 -0
  405. package/tests/task_needs.mjs +233 -0
  406. package/tests/task_needs_db.mjs +110 -0
  407. package/tests/unit_runner_serial.mjs +266 -43
  408. package/tests/version_literals.mjs +5 -7
  409. package/tests/welcome_boot.mjs +16 -5
  410. package/tests/welcome_boot_browser.mjs +36 -4
  411. package/docs/recipes/bongos-cli-release.md +0 -66
  412. package/scripts/bongos/backfill-disciplines.js +0 -144
  413. package/scripts/bongos/backfill-session-earnings.js +0 -123
  414. package/scripts/bongos/backfill-session-types.js +0 -87
  415. package/scripts/bongos/build-bongos.js +0 -94
  416. package/scripts/bongos/cleanup-orphans.js +0 -74
  417. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  418. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  419. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  420. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  421. package/scripts/bongos/smoke-batch-release.sh +0 -316
  422. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  423. package/scripts/bongos/smoke-dependencies.sh +0 -143
  424. package/scripts/gds/backfill-disciplines.js +0 -7
  425. package/scripts/gds/backfill-session-earnings.js +0 -7
  426. package/scripts/gds/backfill-session-types.js +0 -7
  427. package/scripts/gds/cleanup-orphans.js +0 -7
  428. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  429. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  430. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  431. package/scripts/gds/smoke-batch-claim.sh +0 -8
  432. package/scripts/gds/smoke-batch-release.sh +0 -8
  433. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  434. package/scripts/gds/smoke-dependencies.sh +0 -8
  435. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -0,0 +1,282 @@
1
+ // tests/ai_studio_generate.mjs — submitting a sketch and generating on our key
2
+ // (task 1004917, goal 1000136, ADR 0392):
3
+ // - uploads are checked the page-notes way (declared type must match the
4
+ // file's own bytes, size caps, SVG refused), and a request names its files;
5
+ // - the project context is read on the server from the instance's files;
6
+ // - the OpenRouter calls carry the key only in the Authorization header,
7
+ // bill on the cost the provider reports, and fail closed when it is missing;
8
+ // - a whole generation holds the full price first, charges cost x 1.08 on
9
+ // success, refunds everything on a provider failure, never runs on a short
10
+ // balance, and never says "not charged" once money was taken.
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const S = require('../modules/ai-studio/studio.js');
21
+ const OR = require('../modules/ai-studio/openrouter.js');
22
+ const P = require('../modules/ai-studio/pricing.js');
23
+ const payments = require('../modules/ai-studio/payments.js');
24
+ const { runOnOurKey } = require('../modules/ai-studio/generate.js');
25
+ const { projectContext } = require('../modules/ai-studio/context.js');
26
+
27
+ const USD = P.MICRO_PER_USD;
28
+ const KEY = 'sk-or-v1-test-0000000000000000000000000000';
29
+ const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]);
30
+ const PDF = Buffer.from('%PDF-1.7\n%test\n');
31
+
32
+ // ---- uploads and requests --------------------------------------------------
33
+
34
+ test('an upload is accepted only when its bytes are the type it claims', () => {
35
+ const ok = S.checkFile({ name: 'sketch one.png', content_type: 'image/png', data: PNG.toString('base64') });
36
+ assert.equal(ok.file.content_type, 'image/png');
37
+ assert.equal(ok.file.name, 'sketch one.png');
38
+ assert.ok(Buffer.isBuffer(ok.file.bytes));
39
+ assert.equal(S.checkFile({ content_type: 'application/pdf', data: PDF.toString('base64') }).file.content_type, 'application/pdf');
40
+
41
+ assert.equal(S.checkFile({ content_type: 'image/svg+xml', data: Buffer.from('<svg/>').toString('base64') }).error, 'unsupported_type');
42
+ assert.equal(S.checkFile({ content_type: 'image/jpeg', data: PNG.toString('base64') }).error, 'type_mismatch');
43
+ assert.equal(S.checkFile({ content_type: 'image/png', data: '' }).error, 'bad_file');
44
+ const big = Buffer.concat([PNG, Buffer.alloc(S.MAX_IMAGE_BYTES)]);
45
+ assert.equal(S.checkFile({ content_type: 'image/png', data: big.toString('base64') }).error, 'file_too_large');
46
+ assert.equal(S.checkFile({ name: '../../etc/x".png', content_type: 'image/png', data: PNG.toString('base64') }).file.name, '.._.._etc_x_.png');
47
+ for (const code of ['bad_file', 'unsupported_type', 'file_too_large', 'type_mismatch']) assert.ok(S.MESSAGES[code], code);
48
+ });
49
+
50
+ test('a request needs a real prompt and 1 to 6 file ids', () => {
51
+ assert.deepEqual(S.checkRequest({ prompt: ' make it moody ', file_ids: ['4', 4, '9'] }), { prompt: 'make it moody', fileIds: ['4', '9'] });
52
+ assert.equal(S.checkRequest({ prompt: 'hi', file_ids: ['1'] }).error, 'prompt_required');
53
+ assert.equal(S.checkRequest({ prompt: 'x'.repeat(S.MAX_PROMPT + 1), file_ids: ['1'] }).error, 'prompt_too_long');
54
+ assert.equal(S.checkRequest({ prompt: 'a castle', file_ids: [] }).error, 'file_required');
55
+ assert.equal(S.checkRequest({ prompt: 'a castle', file_ids: ['1', '2', '3', '4', '5', '6', '7'] }).error, 'too_many_files');
56
+ assert.equal(S.checkRequest({ prompt: 'a castle', file_ids: ['1; drop table'] }).error, 'bad_file');
57
+ });
58
+
59
+ // ---- project context -------------------------------------------------------
60
+
61
+ test('the project context is read on the server from the instance files', async () => {
62
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-ctx-'));
63
+ fs.mkdirSync(path.join(root, 'docs'));
64
+ fs.writeFileSync(path.join(root, 'docs', 'project-context.template.md'), 'TEMPLATE TEXT');
65
+ fs.writeFileSync(path.join(root, 'DESIGN.md'), 'Night room. Glass rows.');
66
+ const branding = { identity: { productName: 'Moonfall', worldName: 'Tidelands' }, theme: { colors: { accent: '#7af' } } };
67
+
68
+ let ctx = await projectContext({ root, branding });
69
+ assert.match(ctx, /Moonfall, set in the world of Tidelands/);
70
+ assert.match(ctx, /#7af/);
71
+ assert.match(ctx, /TEMPLATE TEXT/, 'falls back to the template when no project doc is written');
72
+ assert.match(ctx, /Night room\. Glass rows\./);
73
+
74
+ fs.writeFileSync(path.join(root, 'docs', 'project-context.md'), 'REAL PROJECT DOC');
75
+ ctx = await projectContext({ root, branding });
76
+ assert.match(ctx, /REAL PROJECT DOC/);
77
+ assert.doesNotMatch(ctx, /TEMPLATE TEXT/);
78
+
79
+ fs.writeFileSync(path.join(root, 'DESIGN.md'), 'd'.repeat(S.CONTEXT_BUDGET.design + 500));
80
+ ctx = await projectContext({ root, branding });
81
+ assert.match(ctx, /cut for length/, 'a long doc is clipped to its budget');
82
+ });
83
+
84
+ // ---- the OpenRouter calls --------------------------------------------------
85
+
86
+ function fakeFetch(responses) {
87
+ const calls = [];
88
+ const fn = async (url, init) => {
89
+ calls.push({ url, init, body: JSON.parse(init.body) });
90
+ const r = responses.shift();
91
+ if (r instanceof Error) throw r;
92
+ return { ok: r.status ? r.status < 400 : true, status: r.status || 200, json: async () => r.json };
93
+ };
94
+ fn.calls = calls;
95
+ return fn;
96
+ }
97
+ const replyJson = (text, cost) => ({ choices: [{ message: { content: text } }], usage: { cost } });
98
+ const imageJson = (cost) => ({
99
+ choices: [{ message: { content: '', images: [{ image_url: { url: `data:image/png;base64,${PNG.toString('base64')}` } }] } }],
100
+ usage: { cost },
101
+ });
102
+ const files = [S.forProvider({ name: 's.png', content_type: 'image/png', bytes: PNG }), S.forProvider({ name: 'd.pdf', content_type: 'application/pdf', bytes: PDF })];
103
+
104
+ test('the reply call sends the key only as a header and bills on the reported cost', async () => {
105
+ const f = fakeFetch([{ json: replyJson('Lovely silhouette.', 0.0123456) }]);
106
+ const out = await OR.reply({ fetchImpl: f, key: KEY, system: 'CTX', prompt: 'a castle', files });
107
+ assert.deepEqual(out, { text: 'Lovely silhouette.', costMicro: 12346 });
108
+ const c = f.calls[0];
109
+ assert.equal(c.url, OR.ENDPOINT);
110
+ assert.equal(c.init.headers.authorization, `Bearer ${KEY}`);
111
+ assert.ok(!c.init.body.includes(KEY), 'the key is never in the request body');
112
+ assert.deepEqual(c.body.usage, { include: true });
113
+ assert.equal(c.body.messages[0].content, 'CTX');
114
+ const parts = c.body.messages[1].content;
115
+ assert.equal(parts[1].type, 'image_url');
116
+ assert.equal(parts[2].type, 'file');
117
+ });
118
+
119
+ test('the image call returns the images and leaves PDFs out of the drawing brief', async () => {
120
+ const f = fakeFetch([{ json: imageJson(0.04) }]);
121
+ const out = await OR.images({ fetchImpl: f, key: KEY, system: 'CTX', brief: 'draw', files });
122
+ assert.equal(out.costMicro, 40000);
123
+ assert.equal(out.images.length, 1);
124
+ assert.equal(out.images[0].content_type, 'image/png');
125
+ assert.deepEqual(f.calls[0].body.modalities, ['image', 'text']);
126
+ assert.equal(f.calls[0].body.messages[1].content.filter((p) => p.type === 'file').length, 0);
127
+ });
128
+
129
+ test('a provider answer with no cost, an error, or no images fails, and no failure echoes the key', async () => {
130
+ const cases = [
131
+ [() => OR.reply({ fetchImpl: fakeFetch([{ json: { choices: [{ message: { content: 'x' } }] } }]), key: KEY, system: '', prompt: 'p', files: [] }), 'provider_cost_unknown'],
132
+ [() => OR.reply({ fetchImpl: fakeFetch([{ status: 401, json: { error: { message: `bad key ${KEY}` } } }]), key: KEY, system: '', prompt: 'p', files: [] }), 'provider_error'],
133
+ [() => OR.reply({ fetchImpl: fakeFetch([new Error(`socket ${KEY}`)]), key: KEY, system: '', prompt: 'p', files: [] }), 'provider_unreachable'],
134
+ [() => OR.images({ fetchImpl: fakeFetch([{ json: replyJson('no pictures', 0.01) }]), key: KEY, system: '', brief: 'b', files: [] }), 'provider_no_images'],
135
+ ];
136
+ for (const [call, code] of cases) {
137
+ await assert.rejects(call, (err) => {
138
+ assert.equal(err.code, code);
139
+ assert.ok(!String(err.message).includes(KEY), `${code} leaked the key`);
140
+ return true;
141
+ });
142
+ }
143
+ });
144
+
145
+ // ---- a whole generation on our key -----------------------------------------
146
+
147
+ function fakeDb() {
148
+ const rows = { ledger: [], outputs: [], finished: null, running: null };
149
+ return {
150
+ rows,
151
+ appendLedger: async (r) => { rows.ledger.push(r); },
152
+ markRunning: async (id, src) => { rows.running = src; },
153
+ addOutput: async (id, builderId, img) => { rows.outputs.push({ id, builderId, img }); },
154
+ finishRequest: async (id, f) => { rows.finished = f; },
155
+ };
156
+ }
157
+ async function funded(usd) {
158
+ const b = payments.createFakeBackend();
159
+ if (usd) await b.topUp(7, { chargeMicro: usd * USD + 1, creditedMicro: usd * USD });
160
+ return b;
161
+ }
162
+ const request = { id: 41, builder_id: 7, prompt: 'a castle at dusk' };
163
+ const okProviders = (calls = []) => ({
164
+ reply: async (a) => { calls.push(['reply', a]); return { text: 'Strong shapes.', costMicro: 12000 }; },
165
+ images: async (a) => { calls.push(['images', a]); return { images: [{ content_type: 'image/png', base64: PNG.toString('base64') }], costMicro: 38000 }; },
166
+ });
167
+
168
+ test('a generation charges exactly cost x 1.08 and records it apart', async () => {
169
+ const pay = await funded(5);
170
+ const db = fakeDb();
171
+ const calls = [];
172
+ const out = await runOnOurKey(request, files, { db, payments: pay, providers: okProviders(calls), fetchImpl: null, ourKey: KEY, context: 'CTX' });
173
+ assert.equal(out.status, 'done');
174
+ const cost = 12000 + 38000;
175
+ const price = P.priceFor(cost);
176
+ assert.equal(price.price_micro, 54000);
177
+ assert.deepEqual(
178
+ { c: db.rows.finished.provider_cost_micro, m: db.rows.finished.markup_micro, p: db.rows.finished.price_micro },
179
+ { c: 50000, m: 4000, p: 54000 });
180
+ assert.equal(db.rows.finished.status, 'done');
181
+ assert.equal(db.rows.finished.reply_md, 'Strong shapes.');
182
+ assert.equal(db.rows.outputs.length, 1);
183
+ assert.equal(db.rows.running, 'ours');
184
+ assert.deepEqual((await pay.balance(7)), { available_micro: 5 * USD - 54000, held_micro: 0 });
185
+ assert.deepEqual(db.rows.ledger.map((r) => r.kind), ['hold', 'capture']);
186
+ assert.ok(db.rows.ledger.every((r) => r.backend === 'fake' && r.requestId === '41'));
187
+ assert.equal(calls[0][1].system, 'CTX', 'the server-built context is what the provider sees');
188
+ assert.match(calls[1][1].brief, /Strong shapes\./, 'the image brief carries the reply');
189
+ });
190
+
191
+ test('a provider failure refunds the whole hold and says so', async () => {
192
+ const pay = await funded(5);
193
+ const db = fakeDb();
194
+ const providers = { ...okProviders(), images: async () => { throw new OR.ProviderError('provider_no_images', 'The AI provider returned no images.'); } };
195
+ const out = await runOnOurKey(request, files, { db, payments: pay, providers, ourKey: KEY, context: '' });
196
+ assert.equal(out.status, 'failed');
197
+ assert.equal(db.rows.finished.status, 'failed');
198
+ assert.match(db.rows.finished.error, /You were not charged\./);
199
+ assert.equal(db.rows.finished.price_micro, undefined);
200
+ assert.deepEqual(await pay.balance(7), { available_micro: 5 * USD, held_micro: 0 });
201
+ assert.deepEqual(db.rows.ledger.map((r) => r.kind), ['hold', 'release']);
202
+ });
203
+
204
+ test('a short balance refuses before any provider call', async () => {
205
+ const pay = await funded(0);
206
+ const db = fakeDb();
207
+ const calls = [];
208
+ const out = await runOnOurKey(request, files, { db, payments: pay, providers: okProviders(calls), ourKey: KEY, context: '' });
209
+ assert.equal(out.status, 'refused');
210
+ assert.equal(out.code, 'insufficient_funds');
211
+ assert.equal(calls.length, 0, 'nothing ran');
212
+ assert.match(db.rows.finished.error, /balance is too low/);
213
+ assert.deepEqual(db.rows.ledger, []);
214
+ });
215
+
216
+ test('with no key of ours configured a request is refused and no money is touched', async () => {
217
+ const pay = await funded(5);
218
+ const db = fakeDb();
219
+ const calls = [];
220
+ const out = await runOnOurKey(request, files, { db, payments: pay, providers: okProviders(calls), ourKey: '', context: '' });
221
+ assert.equal(out.status, 'refused');
222
+ assert.equal(calls.length, 0);
223
+ assert.equal(db.rows.running, null);
224
+ assert.deepEqual(await pay.balance(7), { available_micro: 5 * USD, held_micro: 0 });
225
+ });
226
+
227
+ test('once money was taken a storage failure never claims "not charged"', async () => {
228
+ const pay = await funded(5);
229
+ const db = fakeDb();
230
+ db.addOutput = async () => { throw new Error('disk full'); };
231
+ const out = await runOnOurKey(request, files, { db, payments: pay, providers: okProviders(), ourKey: KEY, context: '' });
232
+ assert.equal(out.status, 'failed');
233
+ assert.equal(out.code, 'store_failed');
234
+ assert.doesNotMatch(db.rows.finished.error, /not charged/);
235
+ assert.equal(db.rows.finished.price_micro, 54000, 'the receipt still shows what was paid');
236
+ assert.deepEqual(db.rows.ledger.map((r) => r.kind), ['hold', 'capture']);
237
+ });
238
+
239
+ // ---- the builder's own key (task 1004918) ----------------------------------
240
+
241
+ test('which key runs: theirs whenever they have one, unless they ask for ours', () => {
242
+ const K = require('../modules/ai-studio/keys.js');
243
+ assert.deepEqual(K.chooseKey([], undefined), { source: 'ours' });
244
+ assert.deepEqual(K.chooseKey(['gemini'], undefined), { source: 'own', provider: 'gemini' });
245
+ assert.deepEqual(K.chooseKey(['gemini', 'openrouter'], undefined), { source: 'own', provider: 'openrouter' }, 'OpenRouter first: it reports its cost');
246
+ assert.deepEqual(K.chooseKey(['openrouter'], 'ours'), { source: 'ours' });
247
+ assert.deepEqual(K.chooseKey([], 'own'), { error: 'no_own_key' });
248
+ assert.deepEqual(K.chooseKey(['openrouter'], 'free'), { error: 'bad_pay_with' });
249
+ });
250
+
251
+ test('on their own key no money moves: one own_key row, price 0, cost noted', async () => {
252
+ const { runOnOwnKey } = require('../modules/ai-studio/generate.js');
253
+ const db = fakeDb();
254
+ const out = await runOnOwnKey(request, files, { db, providers: okProviders(), key: 'their-key', provider: 'openrouter', context: '' });
255
+ assert.equal(out.status, 'done');
256
+ assert.equal(db.rows.running, 'own');
257
+ assert.deepEqual([db.rows.finished.provider_cost_micro, db.rows.finished.markup_micro, db.rows.finished.price_micro], [50000, 0, 0]);
258
+ assert.equal(db.rows.ledger.length, 1);
259
+ assert.deepEqual([db.rows.ledger[0].kind, db.rows.ledger[0].amountMicro, db.rows.ledger[0].providerCostMicro], ['own_key', 0, 50000]);
260
+ });
261
+
262
+ test('a failure on their own key records no ledger row and says we charged nothing', async () => {
263
+ const { runOnOwnKey } = require('../modules/ai-studio/generate.js');
264
+ const db = fakeDb();
265
+ const providers = { ...okProviders(), reply: async () => { throw new OR.ProviderError('provider_error', 'The AI provider refused the request (HTTP 401).'); } };
266
+ const out = await runOnOwnKey(request, files, { db, providers, key: 'their-key', provider: 'openrouter', context: '' });
267
+ assert.equal(out.status, 'failed');
268
+ assert.match(db.rows.finished.error, /Nothing was charged by us/);
269
+ assert.deepEqual(db.rows.ledger, []);
270
+ assert.equal(db.rows.outputs.length, 0);
271
+ });
272
+
273
+ // ---- the key never leaves the server ---------------------------------------
274
+
275
+ test('the routes never send our key or log a request body', () => {
276
+ const src = fs.readFileSync(new URL('../modules/ai-studio/routes/ai-studio.js', import.meta.url), 'utf8');
277
+ const mentions = src.split('\n').filter((l) => l.includes('OPENROUTER_API_KEY') && !/^\s*\/\//.test(l));
278
+ assert.equal(mentions.length, 1, 'our key is read in exactly one place');
279
+ assert.match(mentions[0], /ourKey: process\.env\.OPENROUTER_API_KEY/);
280
+ assert.doesNotMatch(src, /log\.\w+\([^)]*req\.body/, 'no log line carries a request body');
281
+ assert.doesNotMatch(src, /res\.json\([^)]*ourKey/);
282
+ });
@@ -0,0 +1,159 @@
1
+ // tests/ai_studio_keys.mjs — a builder's own AI provider keys (task 1004919,
2
+ // goal 1000136, ADR 0392): saved only through the Settings route, stored
3
+ // encrypted, and NEVER sent back to the browser or written to a log.
4
+ //
5
+ // Drives the real router over a stubbed db and a stand-in secret box, so it
6
+ // needs no Postgres and no master key.
7
+
8
+ import { test } from 'node:test';
9
+ import { strict as assert } from 'node:assert';
10
+ import { createRequire } from 'node:module';
11
+ import http from 'node:http';
12
+ import fs from 'node:fs';
13
+
14
+ const require = createRequire(import.meta.url);
15
+ const K = require('../modules/ai-studio/keys.js');
16
+ const db = require('../modules/ai-studio/db.js');
17
+ const api = require('../src/module-api.js');
18
+
19
+ const SECRET = 'sk-or-v1-THIS-IS-A-TEST-KEY-abcdef0123456789WXYZ';
20
+
21
+ // ---- pure ------------------------------------------------------------------
22
+
23
+ test('cleanKey trims, refuses blanks, spaces and silly lengths, and never echoes the key', () => {
24
+ assert.deepEqual(K.cleanKey(` ${SECRET}\n`), { key: SECRET });
25
+ assert.equal(K.cleanKey('').error, 'key_required');
26
+ assert.equal(K.cleanKey(undefined).error, 'key_required');
27
+ assert.equal(K.cleanKey('short').error, 'key_wrong_length');
28
+ assert.equal(K.cleanKey('abcdefgh ijklmnopqrstu').error, 'key_has_spaces');
29
+ for (const bad of ['short', 'abcdefgh ijklmnopqrstu']) assert.ok(!JSON.stringify(K.cleanKey(bad)).includes(bad));
30
+ });
31
+
32
+ test('publicKey carries no field that could hold the key or its ciphertext', () => {
33
+ const row = { provider: 'openrouter', key_enc: 'v1.iv.tag.CIPHERTEXT', last4: 'WXYZ', set_at: '2026-10-28T00:00:00Z', plaintext: SECRET };
34
+ const out = K.publicKey('openrouter', row);
35
+ assert.deepEqual(Object.keys(out).sort(), ['label', 'last4', 'learn_more_url', 'provider', 'set', 'set_at']);
36
+ assert.equal(out.set, true);
37
+ assert.equal(out.last4, 'WXYZ');
38
+ assert.ok(!JSON.stringify(out).includes('CIPHERTEXT'));
39
+ assert.ok(!JSON.stringify(out).includes(SECRET));
40
+ assert.deepEqual(K.listPublic([]).map((k) => [k.provider, k.set]), [['openrouter', false], ['gemini', false]]);
41
+ });
42
+
43
+ test('the migration stores only ciphertext and the last four, for known providers', () => {
44
+ const sql = fs.readFileSync(new URL('../modules/ai-studio/migrations/ai_studio_001_keys.sql', import.meta.url), 'utf8');
45
+ assert.match(sql, /key_enc\s+TEXT\s+NOT NULL/);
46
+ assert.match(sql, /CHECK \(key_enc LIKE 'v1\.%'\)/);
47
+ assert.match(sql, /CHECK \(provider IN \('openrouter', 'gemini'\)\)/);
48
+ assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('ai_studio_001_keys'\)/);
49
+ assert.deepEqual(Object.keys(K.PROVIDERS).sort(), ['gemini', 'openrouter'], 'the CHECK and PROVIDERS must name the same providers');
50
+ });
51
+
52
+ // ---- the routes ------------------------------------------------------------
53
+
54
+ const store = new Map();
55
+ db.listKeyMeta = async (builderId) => [...store.values()].filter((r) => r.builder_id === builderId).map((r) => ({ provider: r.provider, last4: r.last4, set_at: r.set_at, key_enc: true }));
56
+ db.setKey = async (builderId, provider, keyEnc, last4) => {
57
+ const row = { builder_id: builderId, provider, key_enc: keyEnc, last4, set_at: '2026-10-28T00:00:00Z' };
58
+ store.set(`${builderId}/${provider}`, row);
59
+ return { provider, last4, set_at: row.set_at, key_enc: true };
60
+ };
61
+ db.clearKey = async (builderId, provider) => store.delete(`${builderId}/${provider}`);
62
+
63
+ let configured = true;
64
+ const box = { isConfigured: () => configured, encrypt: (p) => `v1.iv.tag.${Buffer.from(p).toString('base64url')}`, decrypt: () => { throw new Error('a route must never decrypt'); } };
65
+ const logged = [];
66
+ const capture = (...args) => logged.push(JSON.stringify(args));
67
+ const fakeLog = { info: capture, warn: capture, error: capture, debug: capture, child: () => fakeLog };
68
+ let viewer = { id: 7, rank: 'xenos' };
69
+ for (const [name, value] of Object.entries({
70
+ secretBox: box,
71
+ logger: () => fakeLog,
72
+ requireBuilder: (req, res, next) => { if (!viewer) return res.status(401).json({ error: { code: 'unauthenticated' } }); req.builder = viewer; next(); },
73
+ requireRank: () => (req, res, next) => next(),
74
+ })) Object.defineProperty(api, name, { value, configurable: true, writable: true });
75
+
76
+ const express = require('express');
77
+ const app = express();
78
+ app.use((req, res, next) => {
79
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code, message: opts && opts.message } });
80
+ next();
81
+ });
82
+ app.use(express.json({ limit: '64kb' }));
83
+ app.use('/api/bongos', require('../modules/ai-studio/routes/ai-studio.js')());
84
+ const server = app.listen(0);
85
+ await new Promise((r) => server.once('listening', r));
86
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
87
+ test.after(() => server.close());
88
+
89
+ function call(method, path, body) {
90
+ return new Promise((resolve, reject) => {
91
+ const data = body === undefined ? null : JSON.stringify(body);
92
+ const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
93
+ const chunks = [];
94
+ res.on('data', (c) => chunks.push(c));
95
+ res.on('end', () => { const raw = Buffer.concat(chunks).toString(); resolve({ status: res.statusCode, raw, body: JSON.parse(raw) }); });
96
+ });
97
+ req.on('error', reject);
98
+ if (data) req.write(data);
99
+ req.end();
100
+ });
101
+ }
102
+
103
+ test('THE WHOLE PATH: save a key, list it, replace it, remove it, and the key never comes back', async () => {
104
+ const saved = await call('PUT', '/ai-studio/keys/openrouter', { key: SECRET });
105
+ assert.equal(saved.status, 200);
106
+ assert.equal(saved.body.key.set, true);
107
+ assert.equal(saved.body.key.last4, 'WXYZ');
108
+
109
+ // It is stored encrypted, never as the plaintext.
110
+ const row = store.get('7/openrouter');
111
+ assert.ok(row.key_enc.startsWith('v1.'));
112
+ assert.notEqual(row.key_enc, SECRET);
113
+
114
+ const listed = await call('GET', '/ai-studio/keys');
115
+ assert.equal(listed.status, 200);
116
+ assert.equal(listed.body.storage_configured, true);
117
+ assert.deepEqual(listed.body.keys.map((k) => [k.provider, k.set, k.last4]), [['openrouter', true, 'WXYZ'], ['gemini', false, null]]);
118
+
119
+ const removed = await call('DELETE', '/ai-studio/keys/openrouter');
120
+ assert.equal(removed.body.removed, true);
121
+ assert.equal(removed.body.key.set, false);
122
+
123
+ // Nothing any route answered, and nothing logged, holds the key or its ciphertext.
124
+ for (const r of [saved, listed, removed]) {
125
+ assert.ok(!r.raw.includes(SECRET), 'a response carried the key');
126
+ assert.ok(!r.raw.includes(row.key_enc), 'a response carried the ciphertext');
127
+ }
128
+ assert.ok(logged.length > 0, 'the routes do log, so the next check is not vacuous');
129
+ for (const line of logged) {
130
+ assert.ok(!line.includes(SECRET), 'a log line carried the key');
131
+ assert.ok(!line.includes('WXYZ') || !line.includes(SECRET.slice(0, 12)), 'a log line carried part of the key');
132
+ }
133
+ });
134
+
135
+ test('a builder only ever sees their own keys', async () => {
136
+ viewer = { id: 7, rank: 'xenos' };
137
+ await call('PUT', '/ai-studio/keys/gemini', { key: SECRET });
138
+ viewer = { id: 8, rank: 'xenos' };
139
+ const other = await call('GET', '/ai-studio/keys');
140
+ assert.deepEqual(other.body.keys.map((k) => k.set), [false, false]);
141
+ viewer = { id: 7, rank: 'xenos' };
142
+ await call('DELETE', '/ai-studio/keys/gemini');
143
+ });
144
+
145
+ test('refusals: unknown provider, bad key, no storage, signed out', async () => {
146
+ assert.equal((await call('PUT', '/ai-studio/keys/acme', { key: SECRET })).status, 404);
147
+ const bad = await call('PUT', '/ai-studio/keys/openrouter', { key: 'nope' });
148
+ assert.equal(bad.status, 400);
149
+ assert.ok(!bad.raw.includes('nope'));
150
+ configured = false;
151
+ const off = await call('PUT', '/ai-studio/keys/openrouter', { key: SECRET });
152
+ assert.equal(off.status, 409);
153
+ assert.equal(off.body.error.code, 'storage_not_configured');
154
+ assert.equal(store.size, 0, 'nothing is stored when storage is not set up');
155
+ configured = true;
156
+ viewer = null;
157
+ assert.equal((await call('GET', '/ai-studio/keys')).status, 401);
158
+ viewer = { id: 7, rank: 'xenos' };
159
+ });