@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,26 @@
1
+ {
2
+ "key": "ai-studio",
3
+ "title": "AI studio (artists' generation)",
4
+ "description": "An artist submits a sketch or image set with a prompt in the Studio and gets a Gemini reply plus generated images, with project context added on the server (task 1004916, goal 1000136, ADR 0392). Runs on our provider key against a processor-held balance (provider cost times one named multiplier, funds held before the run, released on failure) or on the builder's own key at no charge. Keys are entered only in Settings and never leave the server. default:false: enabled where billing is wanted, the hub first.",
5
+ "version": "1.0.0",
6
+ "coreVersion": "^1.19.0",
7
+ "category": {
8
+ "parent": "money"
9
+ },
10
+ "author": "Cloud Bongos LLC",
11
+ "origin": "Cloud Bongos (cloudbongos.com)",
12
+ "license": "AGPL-3.0",
13
+ "maintainer": "Cloud Bongos maintainers",
14
+ "default": false,
15
+ "maintenance": {
16
+ "status": "maintained"
17
+ },
18
+ "migrations": true,
19
+ "contributes": {
20
+ "routes": [
21
+ "ai-studio"
22
+ ]
23
+ },
24
+ "provides": [],
25
+ "consumes": []
26
+ }
@@ -0,0 +1,90 @@
1
+ // modules/ai-studio/openrouter.js — the two OpenRouter calls a generation makes
2
+ // (task 1004917, goal 1000136, ADR 0392): a Gemini REPLY to the artist's
3
+ // submission, then IMAGES drawn from the prompt, the reply and the sketch.
4
+ //
5
+ // Why OpenRouter for both: one account, one key, and every response reports
6
+ // what the call really cost (`usage.cost`, in dollars), which is exactly the
7
+ // number the builder is billed on (cost x the multiplier). A call that does not
8
+ // report its cost is treated as a failure, so the hold is released and nobody
9
+ // is charged a guess.
10
+ //
11
+ // `fetch` is handed in, so tests never touch the network.
12
+
13
+ 'use strict';
14
+
15
+ const ENDPOINT = 'https://openrouter.ai/api/v1/chat/completions';
16
+ const REPLY_MODEL = 'google/gemini-2.5-pro';
17
+ const IMAGE_MODEL = 'google/gemini-2.5-flash-image';
18
+ const TIMEOUT_MS = 120000;
19
+
20
+ class ProviderError extends Error {
21
+ constructor(code, message) { super(message); this.code = code; }
22
+ }
23
+
24
+ // The artist's files as chat content parts: images inline, PDFs as files.
25
+ function fileParts(files) {
26
+ return (files || []).map((f) => (f.content_type === 'application/pdf'
27
+ ? { type: 'file', file: { filename: f.name || 'document.pdf', file_data: `data:application/pdf;base64,${f.base64}` } }
28
+ : { type: 'image_url', image_url: { url: `data:${f.content_type};base64,${f.base64}` } }));
29
+ }
30
+
31
+ async function post(fetchImpl, key, body) {
32
+ const ctl = new AbortController();
33
+ const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS);
34
+ let res;
35
+ try {
36
+ res = await fetchImpl(ENDPOINT, {
37
+ method: 'POST',
38
+ headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json', 'x-title': 'Cloud Bongos AI studio' },
39
+ body: JSON.stringify({ ...body, usage: { include: true } }),
40
+ signal: ctl.signal,
41
+ });
42
+ } catch (err) {
43
+ throw new ProviderError(err.name === 'AbortError' ? 'provider_timeout' : 'provider_unreachable', 'The AI provider could not be reached.');
44
+ } finally {
45
+ clearTimeout(timer);
46
+ }
47
+ let json = null;
48
+ try { json = await res.json(); } catch (_) { /* fall through */ }
49
+ if (!res.ok || !json || json.error) {
50
+ throw new ProviderError('provider_error', `The AI provider refused the request (HTTP ${res.status}).`);
51
+ }
52
+ const cost = json.usage && Number(json.usage.cost);
53
+ if (!Number.isFinite(cost) || cost < 0) throw new ProviderError('provider_cost_unknown', 'The AI provider did not report what the call cost.');
54
+ const msg = (json.choices && json.choices[0] && json.choices[0].message) || {};
55
+ return { message: msg, costMicro: Math.ceil(cost * 1000000) };
56
+ }
57
+
58
+ // Gemini's reply to the submission. Returns { text, costMicro }.
59
+ async function reply({ fetchImpl, key, system, prompt, files }) {
60
+ const out = await post(fetchImpl, key, {
61
+ model: REPLY_MODEL,
62
+ messages: [
63
+ { role: 'system', content: system },
64
+ { role: 'user', content: [{ type: 'text', text: prompt }, ...fileParts(files)] },
65
+ ],
66
+ });
67
+ const text = typeof out.message.content === 'string' ? out.message.content.trim() : '';
68
+ if (!text) throw new ProviderError('provider_empty', 'The AI provider returned no reply.');
69
+ return { text, costMicro: out.costMicro };
70
+ }
71
+
72
+ // Images drawn from the brief. Returns { images: [{ content_type, base64 }], costMicro }.
73
+ async function images({ fetchImpl, key, system, brief, files }) {
74
+ const out = await post(fetchImpl, key, {
75
+ model: IMAGE_MODEL,
76
+ modalities: ['image', 'text'],
77
+ messages: [
78
+ { role: 'system', content: system },
79
+ { role: 'user', content: [{ type: 'text', text: brief }, ...fileParts((files || []).filter((f) => f.content_type !== 'application/pdf'))] },
80
+ ],
81
+ });
82
+ const list = (out.message.images || [])
83
+ .map((i) => /^data:(image\/(?:png|jpeg|webp));base64,(.+)$/.exec((i.image_url && i.image_url.url) || ''))
84
+ .filter(Boolean)
85
+ .map((m) => ({ content_type: m[1], base64: m[2] }));
86
+ if (!list.length) throw new ProviderError('provider_no_images', 'The AI provider returned no images.');
87
+ return { images: list, costMicro: out.costMicro };
88
+ }
89
+
90
+ module.exports = { ENDPOINT, ProviderError, reply, images };
@@ -0,0 +1,105 @@
1
+ // modules/ai-studio/payments.js — the payments port: where a builder's AI
2
+ // balance actually lives (task 1004920, goal 1000136, ADR 0392 decisions 2 and 7).
3
+ //
4
+ // Bongos holds no user money (ADR 0385). The balance is the PROCESSOR's; this
5
+ // port is how we read it and instruct holds against it. Two backends:
6
+ //
7
+ // 'none' (the default) payments are not set up: no balance, no top-up, and a
8
+ // paid generation is refused. A builder's own key still works.
9
+ // 'fake' (test mode) an in-memory stand-in for the processor, chosen only by
10
+ // AI_STUDIO_PAYMENTS=fake. Deterministic, so tests and the
11
+ // hall preview can exercise every path with no card.
12
+ //
13
+ // A real processor adapter joins this table once the owner supplies its keys in
14
+ // Settings; nothing outside this file changes when it does.
15
+ //
16
+ // Every amount is integer micro-dollars (pricing.js).
17
+
18
+ 'use strict';
19
+
20
+ const crypto = require('node:crypto');
21
+
22
+ // ---- the 'fake' backend -------------------------------------------------------
23
+
24
+ function createFakeBackend() {
25
+ const accounts = new Map(); // builderId -> { available, held }
26
+ const holds = new Map(); // holdId -> { builderId, amount, state }
27
+ const acct = (id) => {
28
+ const k = String(id);
29
+ if (!accounts.has(k)) accounts.set(k, { available: 0, held: 0 });
30
+ return accounts.get(k);
31
+ };
32
+ const ref = (p) => `fake_${p}_${crypto.randomBytes(6).toString('hex')}`;
33
+
34
+ return {
35
+ name: 'fake',
36
+ testMode: true,
37
+ async balance(builderId) {
38
+ const a = acct(builderId);
39
+ return { available_micro: a.available, held_micro: a.held };
40
+ },
41
+ // A card payment of chargeMicro that credits creditedMicro to the balance.
42
+ async topUp(builderId, { chargeMicro, creditedMicro }) {
43
+ if (!(chargeMicro >= creditedMicro)) throw new Error('a top-up charge must cover what it credits');
44
+ acct(builderId).available += creditedMicro;
45
+ return { ref: ref('pay') };
46
+ },
47
+ async hold(builderId, amountMicro) {
48
+ const a = acct(builderId);
49
+ if (a.available < amountMicro) return { ok: false, reason: 'insufficient_funds', available_micro: a.available };
50
+ a.available -= amountMicro;
51
+ a.held += amountMicro;
52
+ const holdId = ref('hold');
53
+ holds.set(holdId, { builderId: String(builderId), amount: amountMicro, state: 'held' });
54
+ return { ok: true, holdId };
55
+ },
56
+ // Take amountMicro (at most the hold) and return the rest to the balance.
57
+ async capture(holdId, amountMicro) {
58
+ const h = holds.get(holdId);
59
+ if (!h || h.state !== 'held') throw new Error('no open hold');
60
+ if (amountMicro > h.amount) throw new Error('cannot capture more than was held');
61
+ const a = acct(h.builderId);
62
+ a.held -= h.amount;
63
+ a.available += h.amount - amountMicro;
64
+ h.state = 'captured';
65
+ return { captured_micro: amountMicro, released_micro: h.amount - amountMicro };
66
+ },
67
+ async release(holdId) {
68
+ const h = holds.get(holdId);
69
+ if (!h || h.state !== 'held') return { released_micro: 0 };
70
+ const a = acct(h.builderId);
71
+ a.held -= h.amount;
72
+ a.available += h.amount;
73
+ h.state = 'released';
74
+ return { released_micro: h.amount };
75
+ },
76
+ };
77
+ }
78
+
79
+ // ---- the 'none' backend -------------------------------------------------------
80
+
81
+ const NONE = Object.freeze({
82
+ name: 'none',
83
+ testMode: false,
84
+ async balance() { return null; },
85
+ async topUp() { throw Object.assign(new Error('payments are not set up'), { code: 'payments_not_configured' }); },
86
+ async hold() { return { ok: false, reason: 'payments_not_configured' }; },
87
+ async capture() { throw new Error('payments are not set up'); },
88
+ async release() { return { released_micro: 0 }; },
89
+ });
90
+
91
+ let current = null;
92
+
93
+ // The backend this instance runs on. AI_STUDIO_PAYMENTS names it; anything
94
+ // unrecognised is 'none', so a typo never opens a free balance.
95
+ function backend() {
96
+ if (current) return current;
97
+ const want = String(process.env.AI_STUDIO_PAYMENTS || '').trim().toLowerCase();
98
+ current = want === 'fake' ? createFakeBackend() : NONE;
99
+ return current;
100
+ }
101
+
102
+ // Tests swap in a fresh backend.
103
+ function _setBackend(b) { current = b; }
104
+
105
+ module.exports = { backend, createFakeBackend, NONE, _setBackend };
@@ -0,0 +1,92 @@
1
+ // modules/ai-studio/pricing.js — what an AI generation costs a builder, and what
2
+ // a top-up charges their card (task 1004920, goal 1000136, ADR 0392).
3
+ //
4
+ // Every amount is an integer number of MICRO-DOLLARS (millionths of a dollar):
5
+ // a generation can cost a fraction of a cent, and floats lose money in rounding.
6
+ // The three numbers that set prices are named here and spelled nowhere else:
7
+ //
8
+ // AI_PRICE_MULTIPLIER provider cost x this = what the builder pays (owner,
9
+ // 2026-10-28: 1.08, an 8% markup on every call's real cost)
10
+ // PROCESSOR_PERCENT_BPS the card processor's percentage fee, in basis points
11
+ // PROCESSOR_FIXED_MICRO the card processor's fixed fee per payment
12
+ //
13
+ // The processor's fee is never taken out of the markup: a top-up is grossed up
14
+ // so the balance the builder asked for arrives whole (ADR 0392 decision 4).
15
+ //
16
+ // Pure: no DB, no network.
17
+
18
+ 'use strict';
19
+
20
+ const MICRO_PER_USD = 1000000;
21
+ const MICRO_PER_CENT = 10000;
22
+
23
+ const AI_PRICE_MULTIPLIER = 1.08;
24
+ const PROCESSOR_PERCENT_BPS = 290; // 2.9%
25
+ const PROCESSOR_FIXED_MICRO = 300000; // $0.30
26
+
27
+ // Smallest and largest top-up a builder may ask for, so the fixed fee never
28
+ // dwarfs the balance and a typo cannot charge a fortune.
29
+ const MIN_TOPUP_MICRO = 5 * MICRO_PER_USD;
30
+ const MAX_TOPUP_MICRO = 500 * MICRO_PER_USD;
31
+
32
+ // The multiplier as an exact integer ratio (1.08 -> 10800 / 10000), so the
33
+ // arithmetic below never multiplies money by a float.
34
+ const MULTIPLIER_BPS = Math.round(AI_PRICE_MULTIPLIER * 10000);
35
+
36
+ function isMicro(n) { return Number.isSafeInteger(n) && n >= 0; }
37
+
38
+ // What a call that cost the provider `costMicro` costs the builder: the price,
39
+ // split into the provider's cost and our markup. Rounded UP to the micro-dollar,
40
+ // so the markup is never shaved below the multiplier.
41
+ function priceFor(costMicro) {
42
+ if (!isMicro(costMicro)) throw new TypeError('costMicro must be a non-negative integer');
43
+ const price = Math.ceil((costMicro * MULTIPLIER_BPS) / 10000);
44
+ return { provider_cost_micro: costMicro, markup_micro: price - costMicro, price_micro: price };
45
+ }
46
+
47
+ // The processor's fee on a card payment of `grossCents` (whole cents, as a card
48
+ // is charged), rounded to the cent the way processors round it.
49
+ function processorFeeCents(grossCents) {
50
+ return Math.round((grossCents * PROCESSOR_PERCENT_BPS) / 10000) + PROCESSOR_FIXED_MICRO / MICRO_PER_CENT;
51
+ }
52
+
53
+ // What to charge a card so that, after the processor's fee, `netMicro` reaches
54
+ // the builder's balance. Returns the charge, the fee and the credited amount
55
+ // (the net, rounded up to a whole cent). The smallest such charge is found by
56
+ // solving, then stepping a cent at a time past any rounding edge.
57
+ function topUpCharge(netMicro) {
58
+ if (!isMicro(netMicro)) throw new TypeError('netMicro must be a non-negative integer');
59
+ if (netMicro < MIN_TOPUP_MICRO) throw new RangeError(`a top-up is at least $${MIN_TOPUP_MICRO / MICRO_PER_USD}`);
60
+ if (netMicro > MAX_TOPUP_MICRO) throw new RangeError(`a top-up is at most $${MAX_TOPUP_MICRO / MICRO_PER_USD}`);
61
+ const netCents = Math.ceil(netMicro / MICRO_PER_CENT);
62
+ const fixedCents = PROCESSOR_FIXED_MICRO / MICRO_PER_CENT;
63
+ let gross = Math.ceil(((netCents + fixedCents) * 10000) / (10000 - PROCESSOR_PERCENT_BPS));
64
+ while (gross - processorFeeCents(gross) < netCents) gross += 1;
65
+ while ((gross - 1) - processorFeeCents(gross - 1) >= netCents) gross -= 1;
66
+ return {
67
+ charge_micro: gross * MICRO_PER_CENT,
68
+ processor_fee_micro: processorFeeCents(gross) * MICRO_PER_CENT,
69
+ credited_micro: netCents * MICRO_PER_CENT,
70
+ };
71
+ }
72
+
73
+ // "$0.0432" for a sub-cent amount, "$12.50" otherwise: a builder sees what a
74
+ // generation really cost, not a rounded zero.
75
+ function formatUsd(micro) {
76
+ const usd = micro / MICRO_PER_USD;
77
+ if (micro !== 0 && Math.abs(micro) < MICRO_PER_CENT) return `$${usd.toFixed(4)}`;
78
+ return `$${usd.toFixed(2)}`;
79
+ }
80
+
81
+ module.exports = {
82
+ AI_PRICE_MULTIPLIER,
83
+ PROCESSOR_PERCENT_BPS,
84
+ PROCESSOR_FIXED_MICRO,
85
+ MIN_TOPUP_MICRO,
86
+ MAX_TOPUP_MICRO,
87
+ MICRO_PER_USD,
88
+ priceFor,
89
+ processorFeeCents,
90
+ topUpCharge,
91
+ formatUsd,
92
+ };
@@ -0,0 +1,252 @@
1
+ // modules/ai-studio/routes/ai-studio.js — the AI studio's HTTP surface (goal
2
+ // 1000136, ADR 0392).
3
+ //
4
+ // GET /ai-studio/status is the module on
5
+ // GET /ai-studio/keys my provider keys: set or not, last four, when
6
+ // PUT /ai-studio/keys/:provider save or replace my key { key }
7
+ // DELETE /ai-studio/keys/:provider remove my key
8
+ // GET /ai-studio/balance my balance (read from the processor) + my recent ledger
9
+ // GET /ai-studio/topup/quote what a top-up would charge the card ?amount_usd=
10
+ // POST /ai-studio/topup add to my balance { amount_usd }
11
+ // POST /ai-studio/uploads upload one sketch / image / PDF { name, content_type, data }
12
+ // POST /ai-studio/requests send a request on my uploads { prompt, file_ids }
13
+ // GET /ai-studio/requests my recent requests
14
+ // GET /ai-studio/requests/:id one request: reply, images, price
15
+ // GET /ai-studio/files/:id one of my files (upload or output)
16
+ //
17
+ // A request answers 202 at once and runs in the background; the page polls
18
+ // GET /ai-studio/requests/:id until it is done, failed or refused.
19
+ //
20
+ // rank: builder — every route needs a signed-in builder, and a builder only
21
+ // ever touches their own keys. A key arrives here only from the Settings page;
22
+ // no route returns it, and no log line carries it or the request body.
23
+
24
+ 'use strict';
25
+
26
+ const express = require('express');
27
+ const api = require('../../../src/module-api');
28
+ const K = require('../keys');
29
+ const db = require('../db');
30
+ const P = require('../pricing');
31
+ const payments = require('../payments');
32
+ const S = require('../studio');
33
+ const openrouter = require('../openrouter');
34
+ const gemini = require('../gemini');
35
+ const { runOnOurKey, runOnOwnKey } = require('../generate');
36
+ const { projectContext } = require('../context');
37
+
38
+ const log = api.logger('ai-studio');
39
+
40
+ const ID_RE = /^\d{1,18}$/;
41
+ const FILE_URL_PREFIX = '/api/bongos/ai-studio/files/';
42
+ const CLIENTS = { openrouter, gemini };
43
+
44
+ // Runs one request in the background, on the key `choice` names. Our key is
45
+ // the server's own OPENROUTER_API_KEY; without it a request is refused with a
46
+ // pointer to Settings. A builder's own key is decrypted here, inside the run,
47
+ // and handed only to the provider call: it is never logged or stored again.
48
+ function runInBackground(request, choice) {
49
+ (async () => {
50
+ const files = (await db.requestUploads(request.id)).map(S.forProvider);
51
+ const context = await projectContext();
52
+ if (choice.source === 'own') {
53
+ let key;
54
+ try { key = api.secretBox.decrypt(await db.getKeyEnc(request.builder_id, choice.provider)); } catch (_) { key = null; }
55
+ if (!key) {
56
+ await db.finishRequest(request.id, { status: 'failed', error: 'Your saved key could not be read. Enter it again in Settings. Nothing was charged.' });
57
+ return;
58
+ }
59
+ await runOnOwnKey(request, files, { db, providers: CLIENTS[choice.provider], fetchImpl: fetch, key, provider: choice.provider, context, log });
60
+ return;
61
+ }
62
+ await runOnOurKey(request, files, {
63
+ db, payments: payments.backend(), providers: openrouter, fetchImpl: fetch,
64
+ ourKey: process.env.OPENROUTER_API_KEY || '', context, log,
65
+ });
66
+ })().catch((err) => {
67
+ log.error({ request_id: request.id, err: err.message }, 'ai-studio: request run crashed');
68
+ db.finishRequest(request.id, { status: 'failed', error: 'The generation could not run. You were not charged.' }).catch(() => {});
69
+ });
70
+ }
71
+
72
+ module.exports = function buildAiStudioRouter() {
73
+ const router = express.Router();
74
+
75
+ router.get('/ai-studio/status', api.requireBuilder, api.asyncHandler('GET /ai-studio/status', async (req, res) => {
76
+ res.json({ ok: true, module: 'ai-studio' });
77
+ }));
78
+
79
+ router.get('/ai-studio/keys', api.requireBuilder, api.asyncHandler('GET /ai-studio/keys', async (req, res) => {
80
+ const rows = await db.listKeyMeta(req.builder.id);
81
+ res.json({ storage_configured: api.secretBox.isConfigured(), keys: K.listPublic(rows) });
82
+ }));
83
+
84
+ router.put('/ai-studio/keys/:provider', api.requireBuilder, api.asyncHandler('PUT /ai-studio/keys/:provider', async (req, res) => {
85
+ const { provider } = req.params;
86
+ if (api.validateOrRespond(req, res, { key: { required: true, type: 'string', maxLength: K.MAX_KEY } })) return;
87
+ if (!K.isProvider(provider)) return res.fail('unknown_provider', { status: 404, message: 'No such AI provider.' });
88
+ if (!api.secretBox.isConfigured()) {
89
+ return res.fail('storage_not_configured', { status: 409, message: 'Key storage is not set up on this project yet.' });
90
+ }
91
+ const cleaned = K.cleanKey(req.body && req.body.key);
92
+ if (cleaned.error) return res.fail(cleaned.error, { status: 400, message: 'That does not look like a key. Paste the whole key, with no spaces.' });
93
+ const row = await db.setKey(req.builder.id, provider, api.secretBox.encrypt(cleaned.key), K.last4(cleaned.key));
94
+ log.info({ builder_id: req.builder.id, provider }, 'provider key saved');
95
+ res.json({ key: K.publicKey(provider, row) });
96
+ }));
97
+
98
+ router.delete('/ai-studio/keys/:provider', api.requireBuilder, api.asyncHandler('DELETE /ai-studio/keys/:provider', async (req, res) => {
99
+ const { provider } = req.params;
100
+ if (!K.isProvider(provider)) return res.fail('unknown_provider', { status: 404, message: 'No such AI provider.' });
101
+ const removed = await db.clearKey(req.builder.id, provider);
102
+ log.info({ builder_id: req.builder.id, provider, removed }, 'provider key removed');
103
+ res.json({ key: K.publicKey(provider, null), removed });
104
+ }));
105
+
106
+ router.get('/ai-studio/balance', api.requireBuilder, api.asyncHandler('GET /ai-studio/balance', async (req, res) => {
107
+ const b = payments.backend();
108
+ const [balance, ledger] = await Promise.all([b.balance(req.builder.id), db.recentLedger(req.builder.id, { limit: 50 })]);
109
+ res.json({
110
+ payments: b.name,
111
+ test_mode: !!b.testMode,
112
+ price_multiplier: P.AI_PRICE_MULTIPLIER,
113
+ // What one generation may cost, so the Studio can say how much is set
114
+ // aside before a run without spelling the number itself (task 1004921).
115
+ max_cost_micro: S.MAX_COST_MICRO,
116
+ balance,
117
+ ledger: ledger.map(publicLedgerRow),
118
+ });
119
+ }));
120
+
121
+ // What a top-up of amount_usd would charge the card, before anything is paid
122
+ // (task 1004921): the Studio shows "your card is charged $20.91" from this, so
123
+ // the processor's fee stays spelled only in pricing.js. Writes nothing.
124
+ router.get('/ai-studio/topup/quote', api.requireBuilder, api.asyncHandler('GET /ai-studio/topup/quote', async (req, res) => {
125
+ const raw = String(req.query.amount_usd || '');
126
+ if (!/^\d{1,4}(\.\d{1,2})?$/.test(raw)) {
127
+ return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
128
+ }
129
+ const usd = Number(raw);
130
+ let quote;
131
+ try { quote = P.topUpCharge(Math.round(usd * P.MICRO_PER_USD)); } catch (err) {
132
+ return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
133
+ }
134
+ res.json({ quote, min_micro: P.MIN_TOPUP_MICRO, max_micro: P.MAX_TOPUP_MICRO });
135
+ }));
136
+
137
+ // Top up the processor-held balance. The card is charged enough that the
138
+ // amount asked for arrives whole after the processor's fee (ADR 0392 d.4).
139
+ router.post('/ai-studio/topup', api.requireBuilder, api.asyncHandler('POST /ai-studio/topup', async (req, res) => {
140
+ if (api.validateOrRespond(req, res, { amount_usd: { required: true, type: 'number' } })) return;
141
+ const b = payments.backend();
142
+ if (b.name === 'none') return res.fail('payments_not_configured', { status: 409, message: 'Paying for generations is not set up here yet.' });
143
+ let quote;
144
+ try { quote = P.topUpCharge(Math.round(req.body.amount_usd * P.MICRO_PER_USD)); } catch (err) {
145
+ return res.fail('bad_amount', { status: 400, message: `Top up between ${P.MIN_TOPUP_MICRO / P.MICRO_PER_USD} and ${P.MAX_TOPUP_MICRO / P.MICRO_PER_USD}.` });
146
+ }
147
+ const paid = await b.topUp(req.builder.id, { chargeMicro: quote.charge_micro, creditedMicro: quote.credited_micro });
148
+ await db.appendLedger({ builderId: req.builder.id, kind: 'topup', amountMicro: quote.credited_micro, processorFeeMicro: quote.processor_fee_micro, backend: b.name, ref: paid.ref, note: `card charged ${P.formatUsd(quote.charge_micro)}` });
149
+ log.info({ builder_id: req.builder.id, credited_micro: quote.credited_micro, backend: b.name }, 'ai balance topped up');
150
+ res.status(201).json({ topup: quote, balance: await b.balance(req.builder.id) });
151
+ }));
152
+
153
+ // One file per call, so a request body stays small; the global 64 KB parser
154
+ // skips this path (src/bongos/routes.js) and this parser takes it.
155
+ router.post('/ai-studio/uploads', api.requireBuilder, express.json({ limit: S.UPLOAD_BODY_LIMIT }), api.asyncHandler('POST /ai-studio/uploads', async (req, res) => {
156
+ if (api.validateOrRespond(req, res, {
157
+ content_type: { required: true, type: 'string', maxLength: 40 },
158
+ data: { required: true, type: 'string', maxLength: 12 * 1024 * 1024 },
159
+ name: { type: 'string', maxLength: 400 },
160
+ })) return;
161
+ const checked = S.checkFile(req.body);
162
+ if (checked.error) return res.fail(checked.error, { status: 400, message: S.MESSAGES[checked.error] });
163
+ const row = await db.addUpload(req.builder.id, checked.file);
164
+ res.status(201).json({ file: publicFile(row) });
165
+ }));
166
+
167
+ router.post('/ai-studio/requests', api.requireBuilder, api.asyncHandler('POST /ai-studio/requests', async (req, res) => {
168
+ if (api.validateOrRespond(req, res, {
169
+ prompt: { required: true, type: 'string', maxLength: S.MAX_PROMPT + 200 },
170
+ file_ids: { required: true, type: 'array', maxItems: S.MAX_FILES },
171
+ pay_with: { type: 'string', enum: ['own', 'ours'] },
172
+ })) return;
173
+ const checked = S.checkRequest(req.body);
174
+ if (checked.error) return res.fail(checked.error, { status: 400, message: S.MESSAGES[checked.error] });
175
+ const stored = (await db.listKeyMeta(req.builder.id)).map((k) => k.provider);
176
+ const choice = K.chooseKey(stored, req.body.pay_with);
177
+ if (choice.error) return res.fail(choice.error, { status: 409, message: 'You have no key of your own saved. Add one in Settings, or pay with your balance.' });
178
+ if (await db.countActiveRequests(req.builder.id) >= S.MAX_ACTIVE_REQUESTS) {
179
+ return res.fail('too_many_running', { status: 429, message: `You have ${S.MAX_ACTIVE_REQUESTS} requests running already. Wait for one to finish.` });
180
+ }
181
+ const request = await db.createRequest(req.builder.id, checked.prompt, checked.fileIds);
182
+ if (!request) return res.fail('file_not_found', { status: 404, message: 'One of those files is not yours, or was already sent with another request.' });
183
+ if (Number(request.attached) !== checked.fileIds.length) {
184
+ await db.finishRequest(request.id, { status: 'refused', error: 'One of those files was sent with another request at the same moment.' });
185
+ return res.fail('file_not_found', { status: 409, message: 'One of those files was already sent with another request.' });
186
+ }
187
+ log.info({ builder_id: req.builder.id, request_id: request.id, files: checked.fileIds.length, key_source: choice.source }, 'ai request queued');
188
+ runInBackground(request, choice);
189
+ res.status(202).json({ request: publicRequest({ ...request, files: [] }) });
190
+ }));
191
+
192
+ router.get('/ai-studio/requests', api.requireBuilder, api.asyncHandler('GET /ai-studio/requests', async (req, res) => {
193
+ const rows = await db.listRequests(req.builder.id, { limit: 30 });
194
+ res.json({ requests: rows.map((r) => publicRequest(r)) });
195
+ }));
196
+
197
+ router.get('/ai-studio/requests/:id', api.requireBuilder, api.asyncHandler('GET /ai-studio/requests/:id', async (req, res) => {
198
+ const id = String(req.params.id || '');
199
+ if (!ID_RE.test(id)) return res.fail('invalid_request_id', 400);
200
+ const row = await db.getRequest(req.builder.id, id);
201
+ if (!row) return res.fail('request_not_found', 404);
202
+ res.json({ request: publicRequest(row) });
203
+ }));
204
+
205
+ router.get('/ai-studio/files/:id', api.requireBuilder, api.asyncHandler('GET /ai-studio/files/:id', async (req, res) => {
206
+ const id = String(req.params.id || '');
207
+ if (!ID_RE.test(id)) return res.fail('invalid_file_id', 400);
208
+ const file = await db.getFile(req.builder.id, id);
209
+ if (!file) return res.fail('file_not_found', 404);
210
+ res.set('Content-Type', file.content_type);
211
+ res.set('X-Content-Type-Options', 'nosniff');
212
+ res.set('Content-Security-Policy', "default-src 'none'");
213
+ // Images show in the page; anything else (a PDF) downloads, so a document
214
+ // an artist uploaded never renders inside the hall's own origin.
215
+ const disposition = file.content_type.startsWith('image/') ? 'inline' : 'attachment';
216
+ res.set('Content-Disposition', `${disposition}; filename="${file.name}"`);
217
+ res.set('Cache-Control', 'private, max-age=3600');
218
+ res.send(file.bytes);
219
+ }));
220
+
221
+ return router;
222
+ };
223
+
224
+ function publicFile(f) {
225
+ return {
226
+ id: String(f.id), role: f.role, name: f.name, content_type: f.content_type,
227
+ size_bytes: Number(f.size_bytes) || 0, url: FILE_URL_PREFIX + f.id,
228
+ };
229
+ }
230
+
231
+ // The request a builder sees: its status, the reply, the files, and the price
232
+ // with the provider's cost and our markup apart.
233
+ function publicRequest(r) {
234
+ const n = (v) => (v === null || v === undefined ? null : Number(v));
235
+ return {
236
+ id: String(r.id), prompt: r.prompt, status: r.status, key_source: r.key_source,
237
+ reply_md: r.reply_md || null, error: r.error || null,
238
+ provider_cost_micro: n(r.provider_cost_micro), markup_micro: n(r.markup_micro), price_micro: n(r.price_micro),
239
+ created_at: r.created_at, finished_at: r.finished_at || null,
240
+ files: (r.files || []).map(publicFile),
241
+ };
242
+ }
243
+
244
+ // The ledger row a builder sees: every amount apart, nothing internal (no ref).
245
+ function publicLedgerRow(r) {
246
+ return {
247
+ id: String(r.id), kind: r.kind, request_id: r.request_id,
248
+ amount_micro: Number(r.amount_micro), provider_cost_micro: Number(r.provider_cost_micro),
249
+ markup_micro: Number(r.markup_micro), processor_fee_micro: Number(r.processor_fee_micro),
250
+ backend: r.backend, note: r.note, created_at: r.created_at,
251
+ };
252
+ }