@bongos/core 1.23.21 → 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 (457) hide show
  1. package/.bongos-core.json +983 -428
  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 +6 -3
  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 +94 -0
  27. package/clients/bongos-client/index.cjs +94 -0
  28. package/clients/bongos-client/index.d.ts +154 -4
  29. package/clients/bongos-client/index.mjs +94 -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 +87 -0
  33. package/docs/adr/0386-the-funding-wall-is-a-per-project-rule-claim-time-hold-by-default.md +37 -0
  34. package/docs/adr/0387-two-ledgers-credits-measure-work-money-stays-with-the-processor.md +39 -0
  35. package/docs/adr/0388-a-module-sale-is-split-by-the-project-the-buyer-pays-the-platform-fee.md +42 -0
  36. package/docs/adr/0392-ai-generation-runs-on-a-processor-held-balance-and-the-builders-own-keys.md +33 -0
  37. package/docs/adr/0393-a-platform-account-is-a-person-and-github-is-one-way-in.md +37 -0
  38. package/docs/adr/0394-market-watch-searches-through-the-messages-api-on-the-projects-key-and-runs-from-its-own-poller.md +102 -0
  39. package/docs/adr/0395-a-task-says-what-it-needs-and-lists-show-only-what-you-can-do.md +36 -0
  40. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +41 -0
  41. package/docs/adr/README.md +10 -1
  42. package/docs/api/openapi.json +8619 -5885
  43. package/docs/api-reference.md +68 -9
  44. package/docs/architecture.md +37 -1
  45. package/docs/canonical-permissions.md +1 -1
  46. package/docs/copy-inventory.md +420 -337
  47. package/docs/copy-registry.json +1321 -500
  48. package/docs/file-map.md +4 -0
  49. package/docs/module-api-changelog.md +7 -0
  50. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +3 -3
  51. package/docs/onboarding/diagrams/README.md +1 -1
  52. package/docs/onboarding/drachmae-and-karma.md +15 -15
  53. package/docs/onboarding/primer.md +2 -2
  54. package/docs/page-inventory.json +55 -6
  55. package/docs/page-readings.json +1185 -1011
  56. package/docs/recipes/google-sign-in.md +60 -0
  57. package/docs/recipes/ops-gotchas.md +1 -1
  58. package/migrations/core_282_builders_lower_login_idx.sql +33 -0
  59. package/migrations/core_283_claimable_ignores_page_markers.sql +53 -0
  60. package/migrations/core_283_payout_approvals.sql +183 -0
  61. package/migrations/core_284_payout_gates.sql +179 -0
  62. package/migrations/core_285_task_needs.sql +74 -0
  63. package/modules/ai-studio/CLAUDE.md +15 -0
  64. package/modules/ai-studio/billing.js +67 -0
  65. package/modules/ai-studio/context.js +29 -0
  66. package/modules/ai-studio/db.js +172 -0
  67. package/modules/ai-studio/gemini.js +75 -0
  68. package/modules/ai-studio/generate.js +119 -0
  69. package/modules/ai-studio/keys.js +73 -0
  70. package/modules/ai-studio/migrations/ai_studio_001_keys.sql +22 -0
  71. package/modules/ai-studio/migrations/ai_studio_002_ledger.sql +48 -0
  72. package/modules/ai-studio/migrations/ai_studio_003_requests.sql +55 -0
  73. package/modules/ai-studio/module.json +26 -0
  74. package/modules/ai-studio/openrouter.js +90 -0
  75. package/modules/ai-studio/payments.js +105 -0
  76. package/modules/ai-studio/pricing.js +92 -0
  77. package/modules/ai-studio/routes/ai-studio.js +252 -0
  78. package/modules/ai-studio/studio.js +121 -0
  79. package/modules/autonomy/fence.js +64 -1
  80. package/modules/builder-settings/builder-needs.js +27 -1
  81. package/modules/copy-desk/routes/copy-desk.js +5 -0
  82. package/modules/economy/achievements.js +22 -10
  83. package/modules/economy/cost-classify.js +9 -0
  84. package/modules/economy/cost-write-guard.js +52 -0
  85. package/modules/economy/cost.js +2 -1
  86. package/modules/economy/credits.js +18 -15
  87. package/modules/economy/goal-reward.js +161 -0
  88. package/modules/economy/reward.js +4 -0
  89. package/modules/economy/routes/cost.js +33 -7
  90. package/modules/government/catalog.js +6 -0
  91. package/modules/government/db.js +108 -0
  92. package/modules/government/docket.js +36 -0
  93. package/modules/government/migrations/government_035_payout_approve.sql +29 -0
  94. package/modules/government/pollers/board-expiry.js +108 -20
  95. package/modules/government/protected-surfaces.json +1 -0
  96. package/modules/government/routes/government.js +50 -18
  97. package/modules/government/routing.js +39 -5
  98. package/modules/government/waiting.js +74 -0
  99. package/modules/grading/grader-subagent.js +30 -11
  100. package/modules/hall-ui/public/ai-studio-lib.js +268 -0
  101. package/modules/hall-ui/public/ai-studio.css +193 -0
  102. package/modules/hall-ui/public/ai-studio.html +98 -0
  103. package/modules/hall-ui/public/ai-studio.js +556 -0
  104. package/modules/hall-ui/public/ai-studio.states.json +94 -0
  105. package/modules/hall-ui/public/blockers-by-person.js +156 -0
  106. package/modules/hall-ui/public/blockers-lib.js +15 -1
  107. package/modules/hall-ui/public/blockers.html +8 -0
  108. package/modules/hall-ui/public/blockers.js +1 -1
  109. package/modules/hall-ui/public/city.html +15 -15
  110. package/modules/hall-ui/public/claim-action.js +3 -0
  111. package/modules/hall-ui/public/collab.html +12 -0
  112. package/modules/hall-ui/public/craft-mode.js +4 -1
  113. package/modules/hall-ui/public/criterion-uat-ui.js +4 -1
  114. package/modules/hall-ui/public/drachmae.html +1 -1
  115. package/modules/hall-ui/public/genesis/r5-life-1-primordial-sea.jpg +0 -0
  116. package/modules/hall-ui/public/genesis/r5-life-2-first-cells-cut.webp +0 -0
  117. package/modules/hall-ui/public/genesis/r5-life-2-first-cells.jpg +0 -0
  118. package/modules/hall-ui/public/genesis/r5-life-3-genome-cut.webp +0 -0
  119. package/modules/hall-ui/public/genesis/r5-life-3-genome.jpg +0 -0
  120. package/modules/hall-ui/public/genesis/r5-life-4-membrane-cut.webp +0 -0
  121. package/modules/hall-ui/public/genesis/r5-life-4-membrane.jpg +0 -0
  122. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system-cut.webp +0 -0
  123. package/modules/hall-ui/public/genesis/r5-life-5-nervous-system.jpg +0 -0
  124. package/modules/hall-ui/public/goal-inbox.js +1 -1
  125. package/modules/hall-ui/public/goals-render.js +4 -0
  126. package/modules/hall-ui/public/goals.html +8 -0
  127. package/modules/hall-ui/public/gov-living.js +6 -2
  128. package/modules/hall-ui/public/hall-ask.css +5 -0
  129. package/modules/hall-ui/public/hall-ask.js +39 -3
  130. package/modules/hall-ui/public/notes-panel.css +20 -0
  131. package/modules/hall-ui/public/notes-panel.js +146 -0
  132. package/modules/hall-ui/public/project-settings.html +1 -1
  133. package/modules/hall-ui/public/project-settings.js +7 -5
  134. package/modules/hall-ui/public/settings-ai-keys.js +124 -0
  135. package/modules/hall-ui/public/settings.html +15 -0
  136. package/modules/hall-ui/public/street-deck.js +12 -3
  137. package/modules/hall-ui/public/studio.css +90 -8
  138. package/modules/hall-ui/public/studio.html +39 -10
  139. package/modules/hall-ui/public/studio.js +148 -18
  140. package/modules/hall-ui/public/task.html +2 -0
  141. package/modules/hall-ui/public/task.js +4 -0
  142. package/modules/hall-ui/public/tweak-editor.js +12 -3
  143. package/modules/hall-ui/public/welcome-boot.css +42 -6
  144. package/modules/hall-ui/public/welcome-boot.js +20 -6
  145. package/modules/hall-ui/public/work.js +20 -0
  146. package/modules/hall-ui/records/ai-studio.md +36 -0
  147. package/modules/hall-ui/records/blockers-people.md +24 -0
  148. package/modules/lifecycle/db-analytics.js +10 -3
  149. package/modules/lifecycle/db-claims.js +32 -4
  150. package/modules/lifecycle/db-goals.js +48 -18
  151. package/modules/lifecycle/db-shared.js +9 -0
  152. package/modules/lifecycle/db-tasks.js +15 -6
  153. package/modules/lifecycle/db-versions.js +21 -8
  154. package/modules/lifecycle/done-when.js +23 -1
  155. package/modules/lifecycle/lifecycle.js +5 -0
  156. package/modules/lifecycle/migrations/lifecycle_017_notes.sql +46 -0
  157. package/modules/lifecycle/module.json +2 -1
  158. package/modules/lifecycle/notes.js +219 -0
  159. package/modules/lifecycle/page-notice.js +124 -0
  160. package/modules/lifecycle/page-tweak-artist.js +18 -1
  161. package/modules/lifecycle/publish-reconciler.js +67 -20
  162. package/modules/lifecycle/routes/claims.js +5 -2
  163. package/modules/lifecycle/routes/goals.js +3 -1
  164. package/modules/lifecycle/routes/notes.js +91 -0
  165. package/modules/lifecycle/routes/project-settings.js +43 -35
  166. package/modules/lifecycle/routes/task-route-helpers.js +21 -0
  167. package/modules/lifecycle/routes/task-write-routes.js +21 -3
  168. package/modules/lifecycle/routes/tasks.js +12 -12
  169. package/modules/lifecycle/ship-assurance.js +4 -1
  170. package/modules/lifecycle/ship-card.js +8 -2
  171. package/modules/lifecycle/task-needs-gate.js +70 -0
  172. package/modules/market-watch/CLAUDE.md +15 -0
  173. package/modules/market-watch/db.js +340 -0
  174. package/modules/market-watch/key.js +58 -0
  175. package/modules/market-watch/migrations/market_watch_001_tables.sql +249 -0
  176. package/modules/market-watch/migrations/market_watch_002_run.sql +46 -0
  177. package/modules/market-watch/module.json +26 -0
  178. package/modules/market-watch/pollers/hub-seed.js +54 -0
  179. package/modules/market-watch/pollers/weekly-run.js +100 -0
  180. package/modules/market-watch/research.js +350 -0
  181. package/modules/market-watch/run.js +245 -0
  182. package/modules/market-watch/seed/market-data.json +38 -0
  183. package/modules/market-watch/seed/partners-data.json +497 -0
  184. package/modules/market-watch/seed.js +252 -0
  185. package/modules/market-watch/store.js +230 -0
  186. package/modules/memory/routes/memory.js +14 -2
  187. package/modules/memory/routes/search.js +8 -2
  188. package/modules/onboarding/howto-moments.js +8 -1
  189. package/modules/platform-identity/account-logins.js +192 -0
  190. package/modules/platform-identity/migrations/platform_identity_034_account_logins.sql +96 -0
  191. package/modules/platform-identity/platform-identity.js +5 -4
  192. package/modules/platform-identity/routes/my-projects.js +6 -0
  193. package/modules/platform-identity/routes/profile.js +18 -0
  194. package/modules/platform-identity/routes/public-profile.js +10 -1
  195. package/modules/platform-identity/routes/sso.js +26 -1
  196. package/modules/platform-identity/tests/platform-identity.mjs +2 -1
  197. package/modules/provisioning/credential-preflight.js +13 -3
  198. package/modules/provisioning/pollers/app-liveness.js +25 -5
  199. package/modules/provisioning/routes/provisioning.js +1 -1
  200. package/modules/provisioning/starter-bundles.js +8 -2
  201. package/modules/public-landing/public/account.html +10 -2
  202. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg +0 -0
  203. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.jpg.json +1 -1
  204. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp +0 -0
  205. package/modules/public-landing/public/assets/world/r2-bongos-chrome-b.webp.json +1 -1
  206. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp +0 -0
  207. package/modules/public-landing/public/assets/world/r2-chrome-blob-one-cut.webp.json +1 -1
  208. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg +0 -0
  209. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.jpg.json +1 -1
  210. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp +0 -0
  211. package/modules/public-landing/public/assets/world/r2-chrome-blob-one.webp.json +1 -1
  212. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg +0 -0
  213. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.jpg.json +1 -1
  214. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp +0 -0
  215. package/modules/public-landing/public/assets/world/r2-chrome-blobs-set.webp.json +1 -1
  216. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp +0 -0
  217. package/modules/public-landing/public/assets/world/r3-frost-bead-cut.webp.json +1 -1
  218. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp +0 -0
  219. package/modules/public-landing/public/assets/world/r3-frost-pair-cut.webp.json +1 -1
  220. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp +0 -0
  221. package/modules/public-landing/public/assets/world/r3-gold-blob-cut.webp.json +1 -1
  222. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp +0 -0
  223. package/modules/public-landing/public/assets/world/r3-gold-pair-cut.webp.json +1 -1
  224. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp +0 -0
  225. package/modules/public-landing/public/assets/world/r3-obsidian-blob-cut.webp.json +1 -1
  226. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp +0 -0
  227. package/modules/public-landing/public/assets/world/r3-obsidian-pair-cut.webp.json +1 -1
  228. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg +0 -0
  229. package/modules/public-landing/public/assets/world/r3-space-nebula.jpg.json +1 -1
  230. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg +0 -0
  231. package/modules/public-landing/public/assets/world/r4-card-chrome.jpg.json +1 -1
  232. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg +0 -0
  233. package/modules/public-landing/public/assets/world/r4-card-nebula.jpg.json +1 -1
  234. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg +0 -0
  235. package/modules/public-landing/public/assets/world/r5-life-1-primordial-sea.jpg.json +1 -1
  236. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp +0 -0
  237. package/modules/public-landing/public/assets/world/r5-life-2-first-cells-cut.webp.json +1 -1
  238. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg +0 -0
  239. package/modules/public-landing/public/assets/world/r5-life-2-first-cells.jpg.json +1 -1
  240. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp +0 -0
  241. package/modules/public-landing/public/assets/world/r5-life-3-genome-cut.webp.json +1 -1
  242. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg +0 -0
  243. package/modules/public-landing/public/assets/world/r5-life-3-genome.jpg.json +1 -1
  244. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp +0 -0
  245. package/modules/public-landing/public/assets/world/r5-life-4-membrane-cut.webp.json +1 -1
  246. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg +0 -0
  247. package/modules/public-landing/public/assets/world/r5-life-4-membrane.jpg.json +1 -1
  248. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp +0 -0
  249. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system-cut.webp.json +1 -1
  250. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg +0 -0
  251. package/modules/public-landing/public/assets/world/r5-life-5-nervous-system.jpg.json +1 -1
  252. package/modules/public-landing/public/community.html +1 -1
  253. package/modules/public-landing/public/connections.html +2 -2
  254. package/modules/public-landing/public/guild.html +1 -1
  255. package/modules/public-landing/public/invitations.html +2 -2
  256. package/modules/public-landing/public/projects.html +5 -5
  257. package/modules/raise/views/nda.html +2 -0
  258. package/modules/specialities/db.js +32 -1
  259. package/modules/specialities/routes/specialities.js +10 -6
  260. package/modules/status-ui/public/index.html +1 -1
  261. package/package-lock.json +2 -2
  262. package/package.json +1 -1
  263. package/release-notes.json +286 -0
  264. package/scripts/bongos/autobongos-tweak-lane.js +46 -1
  265. package/scripts/bongos/backfill-subagent-rewards.js +2 -2
  266. package/scripts/bongos/claim.js +22 -1
  267. package/scripts/bongos/db-backup-nightly.sh +29 -0
  268. package/scripts/bongos/deploy-divergence.js +7 -0
  269. package/scripts/bongos/doc-cli-guard.js +6 -0
  270. package/scripts/bongos/fitness.js +3 -2
  271. package/scripts/bongos/gen-diagrams.js +3 -3
  272. package/scripts/bongos/lib.sh +4 -5
  273. package/scripts/bongos/migration-namespace.js +13 -1
  274. package/scripts/bongos/plain-cards.js +4 -0
  275. package/scripts/bongos/provision-github-setup.js +3 -1
  276. package/scripts/bongos/provision-units.js +6 -1
  277. package/scripts/bongos/publish-manifest.js +4 -0
  278. package/scripts/bongos/release.js +2 -0
  279. package/scripts/bongos/run-unit-tests.js +268 -82
  280. package/scripts/bongos/seed-tasks.example.json +56 -0
  281. package/scripts/bongos/seed-tasks.js +222 -0
  282. package/scripts/bongos/seed-verify-criteria.js +1 -2
  283. package/scripts/bongos/ship-finish.js +2 -0
  284. package/scripts/bongos/ship-resume.js +2 -0
  285. package/scripts/bongos/strand-lease.js +250 -0
  286. package/scripts/bongos/strand-watch.js +129 -12
  287. package/scripts/bongos/version-literal-guard.js +2 -2
  288. package/scripts/discord/post.js +4 -3
  289. package/scripts/gds/seed-tasks.js +7 -0
  290. package/scripts/gds/{build-bongos.js → strand-lease.js} +3 -3
  291. package/scripts/hall-preview/server.js +21 -0
  292. package/src/bongos/auth-config.js +29 -1
  293. package/src/bongos/auth-discord.js +3 -3
  294. package/src/bongos/auth-github.js +3 -6
  295. package/src/bongos/auth-google.js +217 -0
  296. package/src/bongos/device-approval.js +140 -0
  297. package/src/bongos/llm-pricing.js +17 -1
  298. package/src/bongos/metrics.js +48 -2
  299. package/src/bongos/middleware/rate-limit.js +30 -11
  300. package/src/bongos/module-scope-map.js +16 -1
  301. package/src/bongos/optimizer.js +15 -1
  302. package/src/bongos/payout-approvals.js +342 -0
  303. package/src/bongos/payout-gates.js +364 -0
  304. package/src/bongos/prelaunch-gate.js +7 -9
  305. package/src/bongos/prelaunch-switch.js +20 -0
  306. package/src/bongos/route-rank-check.js +3 -0
  307. package/src/bongos/routes/auth-google.js +246 -0
  308. package/src/bongos/routes/auth.js +38 -1
  309. package/src/bongos/routes/backup.js +33 -9
  310. package/src/bongos/routes/builders.js +57 -30
  311. package/src/bongos/routes/github.js +9 -0
  312. package/src/bongos/routes/me.js +18 -0
  313. package/src/bongos/routes/my-payouts.js +133 -0
  314. package/src/bongos/routes/payout-approvals.js +312 -0
  315. package/src/bongos/routes/payout-gates.js +179 -0
  316. package/src/bongos/routes.js +15 -1
  317. package/src/bongos/secret-box.js +18 -3
  318. package/src/bongos/serve-internal.js +47 -19
  319. package/src/bongos/sign-in-pages.js +138 -0
  320. package/src/bongos/task-needs.js +146 -0
  321. package/src/bongos-downloads.js +18 -1
  322. package/src/instance-config.js +28 -0
  323. package/src/module-api.js +14 -1
  324. package/src/platform-server.js +27 -1
  325. package/tests/account_logins.mjs +326 -0
  326. package/tests/account_logins_db.mjs +161 -0
  327. package/tests/ai_studio_billing.mjs +253 -0
  328. package/tests/ai_studio_generate.mjs +282 -0
  329. package/tests/ai_studio_keys.mjs +159 -0
  330. package/tests/ai_studio_requests.mjs +319 -0
  331. package/tests/auth_backchannel_logout_route.mjs +201 -0
  332. package/tests/auto_satisfy_criteria.mjs +10 -1
  333. package/tests/autobongos_tweak_lane.mjs +144 -0
  334. package/tests/autobongos_tweak_pin_identity.mjs +137 -0
  335. package/tests/backfill_subagent_rewards.mjs +17 -1
  336. package/tests/backup_trigger_lock.mjs +119 -0
  337. package/tests/batched_writes_parity.mjs +340 -0
  338. package/tests/blockers_people.mjs +198 -0
  339. package/tests/bounded_rate_maps.mjs +184 -0
  340. package/tests/claimable_blocker_status.mjs +1 -1
  341. package/tests/claims_batch_routes.mjs +421 -0
  342. package/tests/conductor_main_e2e.mjs +4 -0
  343. package/tests/config_read_cache.mjs +171 -0
  344. package/tests/copy_desk_page_editor.mjs +12 -0
  345. package/tests/cost_write_guard.mjs +200 -0
  346. package/tests/craft_achievements.mjs +33 -10
  347. package/tests/criterion_goal_attach.mjs +1 -1
  348. package/tests/db_backup_nightly.mjs +73 -1
  349. package/tests/deploy_divergence_line.mjs +7 -0
  350. package/tests/effective_visibility_predicate.mjs +12 -0
  351. package/tests/fitness.mjs +19 -0
  352. package/tests/fixtures/per-row-loops-legacy.cjs +245 -0
  353. package/tests/fixtures/seed-tasks/bongos-v1-idea-routing.json +256 -0
  354. package/tests/fixtures/seed-tasks/bv1-profiles.json +592 -0
  355. package/tests/fixtures/seed-tasks/bv2-ideator-surface.json +373 -0
  356. package/tests/fixtures/seed-tasks/fake-api.mjs +80 -0
  357. package/tests/github_one_trip.mjs +34 -0
  358. package/tests/github_repo_visibility.mjs +20 -0
  359. package/tests/goal_achievement.mjs +30 -1
  360. package/tests/goal_achievement_reward.mjs +205 -0
  361. package/tests/goal_archive_disposition.mjs +5 -5
  362. package/tests/goal_sort_staleness.mjs +2 -1
  363. package/tests/goal_tier.mjs +11 -1
  364. package/tests/google_fonts_nonblocking.mjs +84 -0
  365. package/tests/google_sign_in.mjs +401 -0
  366. package/tests/government_board_expiry.mjs +94 -0
  367. package/tests/government_protected_surfaces.mjs +1 -1
  368. package/tests/government_routes.mjs +146 -23
  369. package/tests/grader.mjs +31 -0
  370. package/tests/hall_ai_studio.mjs +347 -0
  371. package/tests/hall_city.mjs +8 -0
  372. package/tests/hall_page_gate_map.mjs +3 -0
  373. package/tests/hall_studio_home.mjs +61 -2
  374. package/tests/home_attention_cards.mjs +1 -1
  375. package/tests/hook_shell_tools.mjs +8 -0
  376. package/tests/hooks_drain.mjs +1 -1
  377. package/tests/host_topology_skips.mjs +2 -0
  378. package/tests/howto_moments.mjs +17 -0
  379. package/tests/human_signal.mjs +141 -0
  380. package/tests/install_script_reader.mjs +51 -0
  381. package/tests/integration_fixture_ids.mjs +82 -0
  382. package/tests/invite_expiry_db.mjs +13 -1
  383. package/tests/lib_sh_resolution.mjs +0 -4
  384. package/tests/market_watch_data.mjs +244 -0
  385. package/tests/market_watch_data_db.mjs +308 -0
  386. package/tests/market_watch_module.mjs +114 -0
  387. package/tests/market_watch_run.mjs +503 -0
  388. package/tests/metrics.mjs +124 -0
  389. package/tests/migration_namespace.mjs +14 -0
  390. package/tests/module_api.mjs +2 -0
  391. package/tests/module_loader.mjs +1 -1
  392. package/tests/module_skills_hook.mjs +69 -0
  393. package/tests/notes.mjs +182 -0
  394. package/tests/own_scope_guard.mjs +5 -0
  395. package/tests/page_tweak_marker_sql.mjs +76 -0
  396. package/tests/page_tweak_no_block.mjs +135 -0
  397. package/tests/page_tweak_notice.mjs +103 -0
  398. package/tests/payout_approvals.mjs +253 -0
  399. package/tests/payout_approvals_db.mjs +106 -0
  400. package/tests/payout_gates.mjs +322 -0
  401. package/tests/payout_gates_db.mjs +113 -0
  402. package/tests/planning_session_skill.mjs +13 -3
  403. package/tests/project_settings_page.mjs +29 -5
  404. package/tests/project_settings_spend_cap_route.mjs +235 -0
  405. package/tests/promotion_sweep.mjs +29 -1
  406. package/tests/provision.mjs +3 -2
  407. package/tests/provisioning_app_status.mjs +18 -0
  408. package/tests/provisioning_credential_preflight.mjs +53 -0
  409. package/tests/provisioning_env_manifest_edit_delete.mjs +223 -0
  410. package/tests/public_landing_projects.mjs +37 -0
  411. package/tests/publish_reconciler_overlap.mjs +60 -0
  412. package/tests/rank_change_broadcast_port.mjs +151 -0
  413. package/tests/seed_tasks.mjs +176 -0
  414. package/tests/seed_verify_criteria.mjs +2 -6
  415. package/tests/session_start_freshness.mjs +66 -1
  416. package/tests/shell_hooks.mjs +209 -0
  417. package/tests/ship_assurance.mjs +28 -0
  418. package/tests/ship_card.mjs +20 -5
  419. package/tests/site_classified_once.mjs +142 -0
  420. package/tests/speciality_db_shape.mjs +23 -0
  421. package/tests/speciality_session_skills.mjs +48 -2
  422. package/tests/strand_lease.mjs +249 -0
  423. package/tests/strand_watch.mjs +58 -0
  424. package/tests/task_criteria_routes.mjs +275 -0
  425. package/tests/task_criteria_unlink_db.mjs +123 -0
  426. package/tests/task_needs.mjs +233 -0
  427. package/tests/task_needs_db.mjs +110 -0
  428. package/tests/unit_runner_serial.mjs +266 -43
  429. package/tests/version_close_route.mjs +1 -1
  430. package/tests/version_literals.mjs +5 -7
  431. package/tests/welcome_boot.mjs +16 -5
  432. package/tests/welcome_boot_browser.mjs +36 -4
  433. package/docs/recipes/bongos-cli-release.md +0 -66
  434. package/scripts/bongos/backfill-disciplines.js +0 -144
  435. package/scripts/bongos/backfill-session-earnings.js +0 -123
  436. package/scripts/bongos/backfill-session-types.js +0 -87
  437. package/scripts/bongos/build-bongos.js +0 -94
  438. package/scripts/bongos/cleanup-orphans.js +0 -74
  439. package/scripts/bongos/seed-bongos-v1-idea-routing-tasks.js +0 -152
  440. package/scripts/bongos/seed-bv1-profiles-tasks.js +0 -135
  441. package/scripts/bongos/seed-bv2-ideator-surface-tasks.js +0 -390
  442. package/scripts/bongos/smoke-batch-claim.sh +0 -238
  443. package/scripts/bongos/smoke-batch-release.sh +0 -316
  444. package/scripts/bongos/smoke-claim-consistency.sh +0 -222
  445. package/scripts/bongos/smoke-dependencies.sh +0 -143
  446. package/scripts/gds/backfill-disciplines.js +0 -7
  447. package/scripts/gds/backfill-session-earnings.js +0 -7
  448. package/scripts/gds/backfill-session-types.js +0 -7
  449. package/scripts/gds/cleanup-orphans.js +0 -7
  450. package/scripts/gds/seed-bongos-v1-idea-routing-tasks.js +0 -6
  451. package/scripts/gds/seed-bv1-profiles-tasks.js +0 -6
  452. package/scripts/gds/seed-bv2-ideator-surface-tasks.js +0 -6
  453. package/scripts/gds/smoke-batch-claim.sh +0 -8
  454. package/scripts/gds/smoke-batch-release.sh +0 -8
  455. package/scripts/gds/smoke-claim-consistency.sh +0 -8
  456. package/scripts/gds/smoke-dependencies.sh +0 -8
  457. package/tests/smoke_dependencies_witness.mjs +0 -46
@@ -0,0 +1,253 @@
1
+ // tests/ai_studio_billing.mjs — what an AI generation costs, and the order
2
+ // money moves in (task 1004920, goal 1000136, ADR 0392):
3
+ // - price = provider cost x 1.08, in integer micro-dollars, never shaved;
4
+ // - a top-up adds the processor's fee so the asked-for amount arrives whole;
5
+ // - the full price is HELD before the call, a short balance refuses and the
6
+ // call never runs, the real price is captured after, and a provider failure
7
+ // releases the whole hold;
8
+ // - the default backend refuses money entirely, and the ledger is append-only.
9
+
10
+ import { test } from 'node:test';
11
+ import { strict as assert } from 'node:assert';
12
+ import { createRequire } from 'node:module';
13
+ import http from 'node:http';
14
+ import fs from 'node:fs';
15
+
16
+ const require = createRequire(import.meta.url);
17
+ const P = require('../modules/ai-studio/pricing.js');
18
+ const payments = require('../modules/ai-studio/payments.js');
19
+ const { runPaid, BillingRefusal } = require('../modules/ai-studio/billing.js');
20
+ const S = require('../modules/ai-studio/studio.js');
21
+
22
+ const USD = P.MICRO_PER_USD;
23
+
24
+ // ---- pricing arithmetic ----------------------------------------------------
25
+
26
+ test('the multiplier is the owner\'s 1.08, and nothing else in the module spells it', () => {
27
+ assert.equal(P.AI_PRICE_MULTIPLIER, 1.08);
28
+ for (const f of ['billing.js', 'payments.js', 'keys.js', 'db.js', 'routes/ai-studio.js', 'generate.js', 'studio.js', 'openrouter.js', 'context.js', 'gemini.js']) {
29
+ const src = fs.readFileSync(new URL(`../modules/ai-studio/${f}`, import.meta.url), 'utf8');
30
+ assert.doesNotMatch(src, /1\.08|\b108\b/, `${f} re-spells the multiplier`);
31
+ }
32
+ });
33
+
34
+ test('price = cost x 1.08: provider cost and markup apart, rounded up, never down', () => {
35
+ assert.deepEqual(P.priceFor(1 * USD), { provider_cost_micro: 1000000, markup_micro: 80000, price_micro: 1080000 });
36
+ assert.deepEqual(P.priceFor(0), { provider_cost_micro: 0, markup_micro: 0, price_micro: 0 });
37
+ // A sub-cent call: 0.0437 cents -> 437 micro x 1.08 = 471.96 -> 472 (up).
38
+ assert.deepEqual(P.priceFor(437), { provider_cost_micro: 437, markup_micro: 35, price_micro: 472 });
39
+ for (const c of [1, 7, 99, 12345, 987654321]) {
40
+ const p = P.priceFor(c);
41
+ assert.ok(p.price_micro >= c * 1.08, `price for ${c} fell below cost x 1.08`);
42
+ assert.ok(p.price_micro - c * 1.08 < 1, `price for ${c} rounded up by more than one micro-dollar`);
43
+ assert.equal(p.provider_cost_micro + p.markup_micro, p.price_micro);
44
+ }
45
+ assert.throws(() => P.priceFor(-1), TypeError);
46
+ assert.throws(() => P.priceFor(1.5), TypeError);
47
+ });
48
+
49
+ test('a top-up charges enough that, after the processor\'s fee, the full amount arrives', () => {
50
+ const q = P.topUpCharge(10 * USD);
51
+ // $10 net: (10 + 0.30) / 0.971 = 10.6076... -> $10.61; fee = round(10.61 x 2.9%) + 0.30 = 0.61.
52
+ assert.deepEqual(q, { charge_micro: 10610000, processor_fee_micro: 610000, credited_micro: 10000000 });
53
+ for (const usd of [5, 7.33, 20, 99.99, 500]) {
54
+ const want = Math.round(usd * USD);
55
+ const r = P.topUpCharge(want);
56
+ const chargeCents = r.charge_micro / 10000;
57
+ assert.ok(r.charge_micro - P.processorFeeCents(chargeCents) * 10000 >= r.credited_micro, `$${usd}: the fee ate into the balance`);
58
+ assert.ok(r.credited_micro >= want, `$${usd}: credited less than asked`);
59
+ // And it is the SMALLEST such charge: one cent less would not cover it.
60
+ assert.ok((chargeCents - 1) - P.processorFeeCents(chargeCents - 1) < r.credited_micro / 10000, `$${usd}: overcharged`);
61
+ }
62
+ assert.throws(() => P.topUpCharge(1 * USD), RangeError);
63
+ assert.throws(() => P.topUpCharge(501 * USD), RangeError);
64
+ });
65
+
66
+ test('formatUsd shows a sub-cent cost instead of a rounded zero', () => {
67
+ assert.equal(P.formatUsd(472), '$0.0005');
68
+ assert.equal(P.formatUsd(10610000), '$10.61');
69
+ assert.equal(P.formatUsd(0), '$0.00');
70
+ });
71
+
72
+ // ---- hold, run, capture / release -----------------------------------------
73
+
74
+ function harness(startUsd) {
75
+ const b = payments.createFakeBackend();
76
+ const rows = [];
77
+ const ledger = async (row) => { rows.push(row); };
78
+ return { b, rows, ledger, fund: () => b.topUp(1, { chargeMicro: startUsd * USD + 1, creditedMicro: startUsd * USD }) };
79
+ }
80
+
81
+ test('THE PAID PATH: hold the most it can cost, run, capture the real price, return the rest', async () => {
82
+ const h = harness(10); await h.fund();
83
+ let sawHeld = null;
84
+ const out = await runPaid({
85
+ builderId: 1, requestId: 'r1', maxCostMicro: 2 * USD, payments: h.b, ledger: h.ledger,
86
+ run: async () => { sawHeld = await h.b.balance(1); return { costMicro: 500000, result: 'pictures' }; },
87
+ });
88
+ // While the call ran, the whole ceiling (2 x 1.08) was already held.
89
+ assert.deepEqual(sawHeld, { available_micro: 10 * USD - 2160000, held_micro: 2160000 });
90
+ assert.equal(out.result, 'pictures');
91
+ assert.equal(out.charge.price_micro, 540000);
92
+ assert.deepEqual(await h.b.balance(1), { available_micro: 10 * USD - 540000, held_micro: 0 });
93
+ assert.deepEqual(h.rows.map((r) => [r.kind, r.amountMicro]), [['hold', 2160000], ['capture', 540000]]);
94
+ const cap = h.rows[1];
95
+ assert.equal(cap.providerCostMicro, 500000);
96
+ assert.equal(cap.markupMicro, 40000);
97
+ });
98
+
99
+ test('a short balance refuses BEFORE the provider is called', async () => {
100
+ const h = harness(5); await h.fund();
101
+ let ran = false;
102
+ await assert.rejects(
103
+ runPaid({ builderId: 1, requestId: 'r2', maxCostMicro: 6 * USD, payments: h.b, ledger: h.ledger, run: async () => { ran = true; return { costMicro: 1 }; } }),
104
+ (err) => err instanceof BillingRefusal && err.code === 'insufficient_funds' && err.needed_micro === 6480000);
105
+ assert.equal(ran, false, 'the generation ran without its funds secured');
106
+ assert.deepEqual(await h.b.balance(1), { available_micro: 5 * USD, held_micro: 0 });
107
+ assert.equal(h.rows.length, 0);
108
+ });
109
+
110
+ test('a provider failure releases the WHOLE hold and records it', async () => {
111
+ const h = harness(10); await h.fund();
112
+ await assert.rejects(
113
+ runPaid({ builderId: 1, requestId: 'r3', maxCostMicro: 3 * USD, payments: h.b, ledger: h.ledger, run: async () => { throw new Error('provider 500'); } }),
114
+ /provider 500/);
115
+ assert.deepEqual(await h.b.balance(1), { available_micro: 10 * USD, held_micro: 0 }, 'money was kept after a failed call');
116
+ assert.deepEqual(h.rows.map((r) => [r.kind, r.amountMicro]), [['hold', 3240000], ['release', 3240000]]);
117
+ });
118
+
119
+ test('a provider reporting more than its ceiling is charged at most the ceiling', async () => {
120
+ const h = harness(10); await h.fund();
121
+ const out = await runPaid({ builderId: 1, requestId: 'r4', maxCostMicro: 1 * USD, payments: h.b, ledger: h.ledger, run: async () => ({ costMicro: 9 * USD }) });
122
+ assert.equal(out.charge.price_micro, 1080000);
123
+ assert.deepEqual(await h.b.balance(1), { available_micro: 10 * USD - 1080000, held_micro: 0 });
124
+ });
125
+
126
+ test('a ledger write failing after money moved does not charge twice or throw', async () => {
127
+ const h = harness(10); await h.fund();
128
+ const errors = [];
129
+ const out = await runPaid({
130
+ builderId: 1, requestId: 'r5', maxCostMicro: 1 * USD, payments: h.b,
131
+ ledger: async () => { throw new Error('db down'); }, log: { error: (o) => errors.push(o) },
132
+ run: async () => ({ costMicro: 100000 }),
133
+ });
134
+ assert.equal(out.charge.price_micro, 108000);
135
+ assert.equal(errors.length, 2);
136
+ assert.deepEqual(await h.b.balance(1), { available_micro: 10 * USD - 108000, held_micro: 0 });
137
+ });
138
+
139
+ test('with payments not set up (the default), nothing can be held or topped up', async () => {
140
+ const saved = process.env.AI_STUDIO_PAYMENTS;
141
+ for (const v of [undefined, '', 'stripe-typo', 'FAKEISH']) {
142
+ if (v === undefined) delete process.env.AI_STUDIO_PAYMENTS; else process.env.AI_STUDIO_PAYMENTS = v;
143
+ payments._setBackend(null);
144
+ assert.equal(payments.backend().name, 'none', `"${v}" opened a backend`);
145
+ }
146
+ if (saved === undefined) delete process.env.AI_STUDIO_PAYMENTS; else process.env.AI_STUDIO_PAYMENTS = saved;
147
+ let ran = false;
148
+ await assert.rejects(
149
+ runPaid({ builderId: 1, requestId: 'r6', maxCostMicro: 1, payments: payments.NONE, ledger: async () => {}, run: async () => { ran = true; } }),
150
+ (err) => err.code === 'payments_not_configured');
151
+ assert.equal(ran, false);
152
+ });
153
+
154
+ test('the ledger table is append-only and has no balance column', () => {
155
+ const sql = fs.readFileSync(new URL('../modules/ai-studio/migrations/ai_studio_002_ledger.sql', import.meta.url), 'utf8');
156
+ assert.match(sql, /BEFORE UPDATE OR DELETE ON ai_studio_ledger/);
157
+ assert.match(sql, /RAISE EXCEPTION 'ai_studio_ledger is append-only'/);
158
+ assert.match(sql, /provider_cost_micro\s+BIGINT/);
159
+ assert.match(sql, /markup_micro\s+BIGINT/);
160
+ assert.match(sql, /processor_fee_micro\s+BIGINT/);
161
+ assert.doesNotMatch(sql.replace(/--.*$/gm, ''), /\bbalance\b/i, 'a balance column would be Bongos holding user money (ADR 0385)');
162
+ const db = fs.readFileSync(new URL('../modules/ai-studio/db.js', import.meta.url), 'utf8');
163
+ assert.doesNotMatch(db, /UPDATE ai_studio_ledger|DELETE FROM ai_studio_ledger/);
164
+ });
165
+
166
+ // ---- the routes ------------------------------------------------------------
167
+
168
+ const db = require('../modules/ai-studio/db.js');
169
+ const api = require('../src/module-api.js');
170
+ const ledgerRows = [];
171
+ db.appendLedger = async (row) => { ledgerRows.push({ ...row, id: ledgerRows.length + 1, created_at: 'now' }); return {}; };
172
+ db.recentLedger = async (builderId) => ledgerRows.filter((r) => r.builderId === builderId).map((r) => ({
173
+ id: r.id, kind: r.kind, request_id: r.requestId || null, amount_micro: r.amountMicro || 0, provider_cost_micro: r.providerCostMicro || 0,
174
+ markup_micro: r.markupMicro || 0, processor_fee_micro: r.processorFeeMicro || 0, backend: r.backend, note: r.note || '', created_at: r.created_at, ref: r.ref,
175
+ })).reverse();
176
+ let viewer = { id: 7, rank: 'xenos' };
177
+ for (const [name, value] of Object.entries({
178
+ requireBuilder: (req, res, next) => { req.builder = viewer; next(); },
179
+ requireRank: () => (req, res, next) => next(),
180
+ })) Object.defineProperty(api, name, { value, configurable: true, writable: true });
181
+
182
+ const express = require('express');
183
+ const app = express();
184
+ app.use((req, res, next) => {
185
+ res.fail = (code, opts) => res.status(typeof opts === 'number' ? opts : (opts && opts.status) || 400).json({ error: { code, message: opts && opts.message } });
186
+ next();
187
+ });
188
+ app.use(express.json());
189
+ app.use('/api/bongos', require('../modules/ai-studio/routes/ai-studio.js')());
190
+ const server = app.listen(0);
191
+ await new Promise((r) => server.once('listening', r));
192
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
193
+ test.after(() => server.close());
194
+
195
+ function call(method, path, body) {
196
+ return new Promise((resolve, reject) => {
197
+ const data = body === undefined ? null : JSON.stringify(body);
198
+ const req = http.request(`${base}${path}`, { method, headers: data ? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {} }, (res) => {
199
+ const chunks = [];
200
+ res.on('data', (c) => chunks.push(c));
201
+ res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString()) }));
202
+ });
203
+ req.on('error', reject);
204
+ if (data) req.write(data);
205
+ req.end();
206
+ });
207
+ }
208
+
209
+ test('routes: top up on the test backend, then read the balance and a ledger with every amount apart', async () => {
210
+ payments._setBackend(payments.createFakeBackend());
211
+ const up = await call('POST', '/ai-studio/topup', { amount_usd: 10 });
212
+ assert.equal(up.status, 201);
213
+ assert.deepEqual(up.body.topup, { charge_micro: 10610000, processor_fee_micro: 610000, credited_micro: 10000000 });
214
+ const bal = await call('GET', '/ai-studio/balance');
215
+ assert.equal(bal.body.payments, 'fake');
216
+ assert.equal(bal.body.test_mode, true);
217
+ assert.equal(bal.body.price_multiplier, 1.08);
218
+ assert.deepEqual(bal.body.balance, { available_micro: 10000000, held_micro: 0 });
219
+ assert.equal(bal.body.ledger[0].kind, 'topup');
220
+ assert.equal(bal.body.ledger[0].processor_fee_micro, 610000);
221
+ assert.ok(!('ref' in bal.body.ledger[0]), 'the processor reference is internal');
222
+ assert.equal((await call('POST', '/ai-studio/topup', { amount_usd: 1 })).status, 400);
223
+ });
224
+
225
+ // Task 1004921: the Studio says "your card is charged $20.91" BEFORE anything is
226
+ // paid, and how much a run sets aside, from these two reads, so neither the fee
227
+ // nor the ceiling is spelled in the browser.
228
+ test('routes: a top-up quote charges what the top-up itself would, and writes nothing', async () => {
229
+ payments._setBackend(payments.createFakeBackend());
230
+ const before = ledgerRows.length;
231
+ const q = await call('GET', '/ai-studio/topup/quote?amount_usd=10');
232
+ assert.equal(q.status, 200);
233
+ assert.deepEqual(q.body.quote, P.topUpCharge(10 * USD));
234
+ assert.equal(q.body.quote.charge_micro, 10610000);
235
+ assert.equal(q.body.min_micro, P.MIN_TOPUP_MICRO);
236
+ assert.equal(q.body.max_micro, P.MAX_TOPUP_MICRO);
237
+ assert.equal((await call('GET', '/ai-studio/topup/quote?amount_usd=20')).body.quote.charge_micro, 20910000);
238
+ assert.equal((await call('GET', '/ai-studio/topup/quote?amount_usd=2')).status, 400);
239
+ assert.equal((await call('GET', '/ai-studio/topup/quote?amount_usd=abc')).status, 400);
240
+ assert.equal(ledgerRows.length, before, 'a quote must not move money or write the ledger');
241
+ const bal = await call('GET', '/ai-studio/balance');
242
+ assert.equal(bal.body.max_cost_micro, S.MAX_COST_MICRO);
243
+ });
244
+
245
+ test('routes: with payments not set up, a top-up is refused and no row is written', async () => {
246
+ payments._setBackend(payments.NONE);
247
+ const before = ledgerRows.length;
248
+ const r = await call('POST', '/ai-studio/topup', { amount_usd: 10 });
249
+ assert.equal(r.status, 409);
250
+ assert.equal(r.body.error.code, 'payments_not_configured');
251
+ assert.equal(ledgerRows.length, before);
252
+ assert.equal((await call('GET', '/ai-studio/balance')).body.balance, null);
253
+ });
@@ -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
+ });