create-ailk 0.2.0 → 0.4.0

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 (292) hide show
  1. package/component-catalog.md +50 -53
  2. package/dist/cli.js +19 -9
  3. package/dist/lib/apply-module-patches.d.ts +47 -0
  4. package/dist/lib/apply-module-patches.js +370 -0
  5. package/dist/lib/extract-module-bundle.d.ts +51 -3
  6. package/dist/lib/extract-module-bundle.js +327 -28
  7. package/dist/lib/fetch-module.d.ts +3 -38
  8. package/dist/lib/fetch-module.js +26 -8
  9. package/dist/lib/fetch-template.d.ts +20 -0
  10. package/dist/lib/fetch-template.js +129 -0
  11. package/dist/lib/module-license-gate.d.ts +10 -0
  12. package/dist/lib/module-license-gate.js +13 -4
  13. package/dist/lib/paid-paths.d.ts +16 -0
  14. package/dist/lib/paid-paths.js +90 -0
  15. package/dist/module-architecture.d.ts +197 -23
  16. package/dist/module-architecture.js +820 -25
  17. package/dist/parse-args.d.ts +9 -2
  18. package/dist/parse-args.js +46 -1
  19. package/dist/programmatic.d.ts +28 -2
  20. package/dist/programmatic.js +115 -8
  21. package/dist/surfaces.d.ts +12 -36
  22. package/dist/surfaces.js +77 -5
  23. package/dist/sync-routes.js +31 -1
  24. package/dist/template-manifest.d.ts +92 -0
  25. package/dist/template-manifest.js +168 -0
  26. package/dist/template-names.d.ts +41 -0
  27. package/dist/template-names.js +44 -0
  28. package/package.json +3 -2
  29. package/site-templates/marketing-site-free/ailk-template.json +8 -0
  30. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +103 -0
  31. package/site-templates/marketing-site-free/content/en/PrivacyPage/privacy.mdx +79 -0
  32. package/site-templates/marketing-site-free/content/en/TermsPage/terms.mdx +80 -0
  33. package/site-templates/marketing-site-free/provenance.json +4 -0
  34. package/templates/.claude/agents/web.md +2 -3
  35. package/templates/.claude/rules/architecture.md +5 -5
  36. package/templates/.claude/skills/README.md +2 -1
  37. package/templates/.env.example +21 -9
  38. package/templates/CONVENTIONS.md +6 -7
  39. package/templates/apps/api/.env.example +9 -0
  40. package/templates/apps/api/CLAUDE.md +33 -5
  41. package/templates/apps/api/package.json +0 -1
  42. package/templates/apps/api/src/__tests__/cors.test.ts +195 -0
  43. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +60 -2
  44. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +159 -0
  45. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +166 -0
  46. package/templates/apps/api/src/config/__tests__/modules.test.ts +76 -0
  47. package/templates/apps/api/src/config/index.ts +19 -0
  48. package/templates/apps/api/src/config/modules.ts +12 -14
  49. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  50. package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +89 -0
  51. package/templates/apps/api/src/lib/slice-load.ts +90 -0
  52. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +52 -32
  53. package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +182 -0
  54. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +293 -0
  55. package/templates/apps/api/src/openapi/index.ts +9 -3
  56. package/templates/apps/api/src/openapi/spec.ts +505 -65
  57. package/templates/apps/api/src/openapi/surface-drift.ts +195 -0
  58. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +14 -0
  59. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +14 -0
  60. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +14 -0
  61. package/templates/apps/api/src/routes/content/index.ts +38 -11
  62. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +17 -0
  63. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +17 -0
  64. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +796 -0
  65. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +10 -0
  66. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +38 -0
  67. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +814 -0
  68. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +10 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +24 -0
  70. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +17 -0
  71. package/templates/apps/api/src/routes/project-listings/index.ts +37 -10
  72. package/templates/apps/api/src/routes/project-listings/me.ts +10 -0
  73. package/templates/apps/api/src/routes/project-listings/patch-draft.ts +5 -0
  74. package/templates/apps/api/src/routes/project-listings/public.ts +5 -0
  75. package/templates/apps/api/src/routes/project-listings/respond.ts +11 -0
  76. package/templates/apps/api/src/routes/project-listings/resume-token.ts +5 -0
  77. package/templates/apps/api/src/routes/project-listings/start.ts +30 -0
  78. package/templates/apps/api/src/routes/project-listings/submit.ts +5 -0
  79. package/templates/apps/api/src/routes/project-listings/verify-email.ts +150 -0
  80. package/templates/apps/api/src/server.ts +504 -137
  81. package/templates/apps/api/src/services/deliverable-fulfillment.ts +10 -0
  82. package/templates/apps/api/src/services/listing-config.ts +58 -0
  83. package/templates/apps/api/src/services/project-listing-resume-email.ts +326 -0
  84. package/templates/apps/api/src/services/project-listings.ts +229 -4
  85. package/templates/apps/api/src/vercel-handler.ts +60 -26
  86. package/templates/apps/mcp/.env.example +8 -0
  87. package/templates/apps/mcp/CLAUDE.md +2 -2
  88. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +21 -0
  89. package/templates/apps/mcp/__tests__/config/modules.test.ts +78 -0
  90. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +1 -1
  91. package/templates/apps/mcp/src/config/modules.ts +14 -13
  92. package/templates/apps/mcp/src/server.ts +8 -4
  93. package/templates/apps/mcp/src/tools/index.ts +7 -22
  94. package/templates/apps/web/.env.example +15 -0
  95. package/templates/apps/web/app/[locale]/(authed)/{waitlist/__tests__ → __tests__}/gate.test.tsx +4 -4
  96. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +2 -1
  97. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +2 -1
  98. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +2 -1
  99. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +1 -0
  100. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +1 -0
  101. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +2 -1
  102. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +1 -0
  103. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +1 -0
  104. package/templates/apps/web/app/[locale]/pricing/page.tsx +40 -0
  105. package/templates/apps/web/app/[locale]/privacy/page.tsx +40 -0
  106. package/templates/apps/web/app/[locale]/terms/page.tsx +40 -0
  107. package/templates/apps/web/app/llms.txt/route.ts +12 -9
  108. package/templates/apps/web/jest.config.cjs +19 -13
  109. package/templates/apps/web/lib/__tests__/server-api.test.ts +61 -0
  110. package/templates/apps/web/lib/server-api.ts +28 -12
  111. package/templates/apps/web/messages/en.json +58 -0
  112. package/templates/apps/web/next.config.mjs +1 -3
  113. package/templates/apps/web/package.json +0 -6
  114. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  115. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  116. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  117. package/templates/apps/web/public/favicon-16x16.png +0 -0
  118. package/templates/apps/web/public/favicon-32x32.png +0 -0
  119. package/templates/apps/web/public/favicon.ico +0 -0
  120. package/templates/apps/web/public/favicon.svg +6 -3
  121. package/templates/apps/web/public/lockup-horizontal.svg +4 -0
  122. package/templates/apps/web/public/logomark.svg +4 -0
  123. package/templates/apps/web/public/site.webmanifest +2 -2
  124. package/templates/apps/web/public/wordmark.svg +4 -0
  125. package/templates/database/CHANGELOG.md +47 -0
  126. package/templates/database/inbox/schema.prisma +67 -0
  127. package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +123 -0
  128. package/templates/database/package.json +1 -1
  129. package/templates/package.json +1 -1
  130. package/templates/.claude/skills/scaffold-commerce/SKILL.md +0 -807
  131. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +0 -71
  132. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +0 -83
  133. package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +0 -370
  134. package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +0 -376
  135. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +0 -72
  136. package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +0 -471
  137. package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +0 -365
  138. package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +0 -60
  139. package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +0 -446
  140. package/templates/apps/api/src/__tests__/server.test.ts +0 -253
  141. package/templates/apps/api/src/bin/deliverable-resend.ts +0 -144
  142. package/templates/apps/api/src/bin/deliverable-upload.ts +0 -114
  143. package/templates/apps/api/src/bin/followup-sweep.ts +0 -44
  144. package/templates/apps/api/src/bin/listing-csv-sweep.ts +0 -46
  145. package/templates/apps/api/src/bin/seed-presets.ts +0 -58
  146. package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +0 -104
  147. package/templates/apps/api/src/bin/session-retention-sweep.ts +0 -46
  148. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -74
  149. package/templates/apps/api/src/lib/stripe.ts +0 -52
  150. package/templates/apps/api/src/lib/tenant-db.ts +0 -225
  151. package/templates/apps/api/src/lib/ws-token.ts +0 -91
  152. package/templates/apps/api/src/middleware/tenant.ts +0 -99
  153. package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +0 -450
  154. package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +0 -262
  155. package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +0 -436
  156. package/templates/apps/api/src/routes/billing/index.ts +0 -36
  157. package/templates/apps/api/src/routes/billing/portal.ts +0 -182
  158. package/templates/apps/api/src/routes/billing/read.ts +0 -75
  159. package/templates/apps/api/src/routes/billing/usage.ts +0 -153
  160. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -687
  161. package/templates/apps/api/src/routes/checkout/index.ts +0 -13
  162. package/templates/apps/api/src/routes/checkout/sessions.ts +0 -197
  163. package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +0 -393
  164. package/templates/apps/api/src/routes/deliverables/index.ts +0 -200
  165. package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +0 -443
  166. package/templates/apps/api/src/routes/flow-checkouts/index.ts +0 -82
  167. package/templates/apps/api/src/routes/flows/README.md +0 -147
  168. package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +0 -752
  169. package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +0 -671
  170. package/templates/apps/api/src/routes/flows/index.ts +0 -202
  171. package/templates/apps/api/src/routes/flows/recommender.ts +0 -443
  172. package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +0 -815
  173. package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +0 -221
  174. package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +0 -566
  175. package/templates/apps/api/src/routes/project-listings/get.ts +0 -70
  176. package/templates/apps/api/src/routes/project-listings/list.ts +0 -47
  177. package/templates/apps/api/src/routes/project-listings/patch.ts +0 -136
  178. package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +0 -490
  179. package/templates/apps/api/src/routes/schedule/index.ts +0 -249
  180. package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +0 -385
  181. package/templates/apps/api/src/routes/slack/actions.ts +0 -177
  182. package/templates/apps/api/src/routes/slack/index.ts +0 -38
  183. package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +0 -577
  184. package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +0 -78
  185. package/templates/apps/api/src/routes/waitlist-experiments/index.ts +0 -41
  186. package/templates/apps/api/src/routes/waitlist-experiments/list.ts +0 -64
  187. package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +0 -42
  188. package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +0 -84
  189. package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +0 -119
  190. package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +0 -238
  191. package/templates/apps/api/src/routes/waitlist-signups/index.ts +0 -101
  192. package/templates/apps/api/src/routes/webhooks/README.md +0 -80
  193. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -315
  194. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -247
  195. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -270
  196. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -198
  197. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -633
  198. package/templates/apps/api/src/routes/webhooks/index.ts +0 -41
  199. package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -481
  200. package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +0 -356
  201. package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +0 -671
  202. package/templates/apps/api/src/routes/workspaces/config.ts +0 -125
  203. package/templates/apps/api/src/routes/workspaces/index.ts +0 -194
  204. package/templates/apps/api/src/routes/workspaces/sessions.ts +0 -218
  205. package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +0 -425
  206. package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +0 -2840
  207. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -393
  208. package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +0 -560
  209. package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +0 -684
  210. package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +0 -406
  211. package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +0 -290
  212. package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +0 -736
  213. package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +0 -475
  214. package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +0 -340
  215. package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +0 -983
  216. package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +0 -71
  217. package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +0 -379
  218. package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +0 -768
  219. package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +0 -1314
  220. package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +0 -341
  221. package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +0 -567
  222. package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +0 -474
  223. package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +0 -354
  224. package/templates/apps/api/src/services/flow-aggregate.ts +0 -232
  225. package/templates/apps/api/src/services/flow-checkouts.ts +0 -123
  226. package/templates/apps/api/src/services/flow-engine.ts +0 -1296
  227. package/templates/apps/api/src/services/lead-promotion.ts +0 -176
  228. package/templates/apps/api/src/services/listing-csv-sweep.ts +0 -455
  229. package/templates/apps/api/src/services/listing-promotion.ts +0 -342
  230. package/templates/apps/api/src/services/playbook-compile.ts +0 -398
  231. package/templates/apps/api/src/services/playbook-render.ts +0 -263
  232. package/templates/apps/api/src/services/project-listing-decision.ts +0 -510
  233. package/templates/apps/api/src/services/project-listing-flow.ts +0 -426
  234. package/templates/apps/api/src/services/project-listing-issue.ts +0 -251
  235. package/templates/apps/api/src/services/recommender-capture.ts +0 -835
  236. package/templates/apps/api/src/services/scheduling/cal-provider.ts +0 -392
  237. package/templates/apps/api/src/services/scheduling/index.ts +0 -63
  238. package/templates/apps/api/src/services/scheduling/types.ts +0 -88
  239. package/templates/apps/api/src/services/tenant-context.ts +0 -451
  240. package/templates/apps/api/src/services/usage-metering.ts +0 -699
  241. package/templates/apps/api/src/services/waitlist-dashboard.ts +0 -947
  242. package/templates/apps/api/src/services/waitlist-experiments.ts +0 -213
  243. package/templates/apps/api/src/services/waitlist-followup.ts +0 -486
  244. package/templates/apps/api/src/services/waitlist-scoring.ts +0 -166
  245. package/templates/apps/api/src/services/waitlist-signups.ts +0 -165
  246. package/templates/apps/mcp/__tests__/schedule_tools.test.ts +0 -242
  247. package/templates/apps/mcp/src/tools/create_booking.ts +0 -58
  248. package/templates/apps/mcp/src/tools/get_event_meta.ts +0 -55
  249. package/templates/apps/mcp/src/tools/get_flow.ts +0 -61
  250. package/templates/apps/mcp/src/tools/list_availability.ts +0 -67
  251. package/templates/apps/mcp/src/tools/submit_flow_step.ts +0 -83
  252. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +0 -309
  253. package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +0 -149
  254. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +0 -273
  255. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +0 -171
  256. package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +0 -179
  257. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +0 -116
  258. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +0 -66
  259. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +0 -61
  260. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +0 -114
  261. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +0 -139
  262. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +0 -85
  263. package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +0 -67
  264. package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +0 -179
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +0 -178
  266. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +0 -160
  267. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +0 -25
  268. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +0 -117
  269. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +0 -58
  270. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +0 -119
  271. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +0 -287
  272. package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +0 -254
  273. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +0 -532
  274. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +0 -121
  275. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +0 -457
  276. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +0 -396
  277. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +0 -114
  278. package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +0 -110
  279. package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +0 -96
  280. package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +0 -117
  281. package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +0 -96
  282. package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +0 -92
  283. package/templates/apps/web/app/[locale]/schedule/page.tsx +0 -82
  284. package/templates/apps/web/content/en/blog/README.txt +0 -25
  285. package/templates/apps/web/content/en/docs/index.mdx +0 -28
  286. package/templates/apps/web/lib/__mocks__/source-server.js +0 -31
  287. package/templates/apps/web/lib/blog-list-data.ts +0 -95
  288. package/templates/apps/web/lib/blog-post-data.ts +0 -79
  289. package/templates/apps/web/lib/source.ts +0 -34
  290. package/templates/apps/web/public/logomark-dark.svg +0 -4
  291. package/templates/apps/web/public/logomark-light.svg +0 -4
  292. package/templates/apps/web/source.config.ts +0 -78
@@ -1,768 +0,0 @@
1
- // oss: false
2
- /**
3
- * @file usage-metering.test.ts
4
- * @description Unit tests for the usage-metering service (ADR 0009 D3
5
- * service-layer pattern; spec docs/specs/3705-s1-usage-metering-service-spec.md §5).
6
- *
7
- * External dependency boundary mocked:
8
- * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
9
- * manual mock (`jest.mock("../../lib/prisma.js")`), extended with the
10
- * FEAT-047 P1 model namespaces (usageEvent/usagePeriod/llmModel/
11
- * llmModelPricing/aiMarkupConfig — #3709/#3710, not yet in the generated
12
- * Prisma client this package compiles against) plus `$transaction`.
13
- * `usage-metering.ts` casts `prisma` to a local structural delegate type
14
- * (`UsageMeteringPrismaDelegates`); this test does the same so the mock's
15
- * `jest.fn()` methods type-check the same way the service's calls do —
16
- * this test harness stands in for the not-yet-existing live AI call
17
- * (AC-UB-02 service-level, per the spec's §1 scope-reality note).
18
- *
19
- * `aiMarkupConfig.findFirst` is given a REALISTIC filtering implementation
20
- * over an in-memory fixture "table" (mirroring lead-promotion.test.ts's
21
- * `seedLeadTable` pattern) so the hierarchy tests (AC-2) exercise the actual
22
- * precedence the service resolves, not just a static per-call stub.
23
- *
24
- * `$transaction` is likewise given REALISTIC commit/rollback semantics over an
25
- * in-memory `committedEvents` store (`installAtomicDb`), while the BASE client's
26
- * `usageEvent.create` models an AUTO-COMMIT write. That asymmetry is what makes
27
- * the H3 atomicity test load-bearing: a non-transactional implementation (write
28
- * the event, THEN upsert) commits the event immediately, so a failing upsert
29
- * strands an orphan row in `committedEvents` and the test fails. Only writes
30
- * routed through `$transaction` roll back.
31
- *
32
- * Security-review coverage (PR #3727): every High/Medium finding carries a test
33
- * that fails without its fix. H1/H2 (per-event `ceil` to whole cents) are
34
- * deliberately NOT addressed — a spec-level billing-fairness decision escalated
35
- * to Sam — so the 360¢ expectation below still encodes the current rounding.
36
- */
37
-
38
- // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
39
- jest.mock("../../lib/prisma.js");
40
-
41
- import type { Id } from "@working-theory/ids";
42
-
43
- import { prisma } from "../../lib/prisma.js";
44
- import type { TenantContext } from "../tenant-context.js";
45
- import {
46
- calculateProviderCost,
47
- recordUsageEvent,
48
- type ModelPricing,
49
- type RecordUsageEventInput,
50
- type RecordUsageResult,
51
- type UsageMeteringPrismaDelegates,
52
- type UsageWriteClient,
53
- } from "../usage-metering";
54
-
55
- // The generated Prisma client doesn't carry the FEAT-047 P1 models yet
56
- // (#3709/#3710 unmerged) — see the file docblock. Same structural cast the
57
- // service source uses.
58
- // eslint-disable-next-line no-restricted-syntax -- prisma's TS type resolves to the real (not-yet-augmented) PrismaClient; the FEAT-047 P1 model delegates (usageEvent/usagePeriod/llmModel/llmModelPricing/aiMarkupConfig, #3709/#3710) don't exist on it yet, so the intermediate `unknown` widening is required, not gratuitous — mirrors the identical cast in usage-metering.ts.
59
- const db = prisma as unknown as UsageMeteringPrismaDelegates;
60
-
61
- /** The delegate methods are typed as plain functions; the mock behind them is a jest.Mock. */
62
- const mockOf = (fn: unknown): jest.Mock => fn as jest.Mock;
63
-
64
- const ORG_ID = "org_00000000-0000-7000-8000-0000000000a1" as Id<"organization">;
65
- const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000b1" as Id<"workspace">;
66
- const USER_ID = "user_00000000-0000-7000-8000-0000000000c1" as Id<"user">;
67
- const PROVIDER = "anthropic";
68
- const MODEL = "claude-fable";
69
-
70
- /** The resolved tenant grain — what `resolveActiveTenant` hands the service (H5). */
71
- const TENANT: TenantContext = {
72
- userId: USER_ID,
73
- organizationId: ORG_ID,
74
- workspaceId: WORKSPACE_ID,
75
- role: "owner",
76
- };
77
-
78
- function baseInput(overrides?: Partial<RecordUsageEventInput>): RecordUsageEventInput {
79
- return {
80
- tenant: TENANT,
81
- provider: PROVIDER,
82
- model: MODEL,
83
- inputTokensNoCacheWrite: 1_000_000,
84
- cacheReadTokens: 0,
85
- outputTokens: 0,
86
- ...overrides,
87
- };
88
- }
89
-
90
- /** The in-memory "committed rows" store the atomicity assertions read (H3). */
91
- let committedEvents: unknown[];
92
-
93
- /**
94
- * Forges a tenant grain that did NOT come from `resolveActiveTenant` — the H5
95
- * bypass (a caller casting around the branded `Id<>` types, or a route stuffing
96
- * request-supplied strings into the grain). The double assertion is the point of
97
- * the test, not an accident: it reproduces exactly the shape the service's
98
- * runtime id-grammar check must reject. "Fixing the type at the boundary" would
99
- * delete the test.
100
- */
101
- function forgedTenant(overrides: Record<string, string>): TenantContext {
102
- // eslint-disable-next-line no-restricted-syntax -- see the docblock above: the unbranded cast IS the attack under test (H5).
103
- return { ...TENANT, ...overrides } as unknown as TenantContext;
104
- }
105
-
106
- /**
107
- * Installs a DB double with REAL transactional semantics:
108
- * - `$transaction(fn)` stages writes and commits them only if `fn` resolves;
109
- * a throw discards the staged rows (ROLLBACK).
110
- * - the BASE client's `usageEvent.create` commits IMMEDIATELY (auto-commit) —
111
- * modelling what a non-transactional implementation would do.
112
- * Both base delegates keep recording their calls, so `toHaveBeenCalled*`
113
- * assertions work regardless of which path a write took.
114
- */
115
- function installAtomicDb(options?: { upsertError?: Error }) {
116
- mockOf(db.usageEvent.create).mockImplementation(
117
- async (args: { data: Record<string, unknown> }) => {
118
- committedEvents.push(args.data); // auto-commit — nothing can roll this back
119
- return { id: "evt_1" };
120
- },
121
- );
122
- mockOf(db.usagePeriod.upsert).mockImplementation(async () => {
123
- if (options?.upsertError) {
124
- throw options.upsertError;
125
- }
126
- return {};
127
- });
128
-
129
- mockOf(db.$transaction).mockImplementation(
130
- async (fn: (tx: UsageWriteClient) => Promise<unknown>) => {
131
- const staged: unknown[] = [];
132
- const tx: UsageWriteClient = {
133
- usageEvent: {
134
- create: async (args) => {
135
- // Record the call on the base mock (so call assertions still work),
136
- // but STAGE the row — it is only committed if the callback resolves.
137
- mockOf(db.usageEvent.create).mock.calls.push([args]);
138
- staged.push(args.data);
139
- return { id: "evt_1" };
140
- },
141
- },
142
- usagePeriod: {
143
- upsert: async (args) =>
144
- mockOf(db.usagePeriod.upsert)(args) as Promise<unknown>,
145
- },
146
- };
147
- const result = await fn(tx);
148
- committedEvents.push(...staged); // COMMIT (a throw above skips this → ROLLBACK)
149
- return result;
150
- },
151
- );
152
- }
153
-
154
- /** Seeds aiMarkupConfig.findFirst with a realistic hierarchy-precedence filter. */
155
- function seedMarkupTable(
156
- rows: Array<{
157
- provider: string | null;
158
- model: string | null;
159
- markupPercent: unknown;
160
- }>,
161
- ) {
162
- mockOf(db.aiMarkupConfig.findFirst).mockImplementation(
163
- async (args: {
164
- where: { organizationId: string; provider: string | null; model: string | null };
165
- }) => {
166
- const { organizationId, provider, model } = args.where;
167
- if (organizationId !== ORG_ID) return null;
168
- const row = rows.find((r) => r.provider === provider && r.model === model);
169
- return row ? { markupPercent: row.markupPercent } : null;
170
- },
171
- );
172
- }
173
-
174
- function seedModel(overrides?: Record<string, unknown>) {
175
- mockOf(db.llmModel.findFirst).mockResolvedValue({
176
- tier: "BASE",
177
- isAutoModel: false,
178
- chargeExtendedContextAt2x: false,
179
- consumptionMultiplier: 1,
180
- ...overrides,
181
- });
182
- }
183
-
184
- function seedPricing(overrides?: Record<string, unknown>) {
185
- mockOf(db.llmModelPricing.findFirst).mockResolvedValue({
186
- inputPricePerMillion: 1,
187
- outputPricePerMillion: 1,
188
- cachedInputPricePerMillion: null,
189
- ...overrides,
190
- });
191
- }
192
-
193
- /** The persisted `UsageEvent` data of the Nth create call. */
194
- function createdEventData(call = 0): Record<string, unknown> {
195
- return mockOf(db.usageEvent.create).mock.calls[call]?.[0]?.data as Record<
196
- string,
197
- unknown
198
- >;
199
- }
200
-
201
- /** The `usagePeriod.upsert` args of the Nth upsert call. */
202
- function upsertArgs(call = 0): {
203
- create: Record<string, unknown>;
204
- update: Record<string, unknown>;
205
- } {
206
- return mockOf(db.usagePeriod.upsert).mock.calls[call]?.[0] as {
207
- create: Record<string, unknown>;
208
- update: Record<string, unknown>;
209
- };
210
- }
211
-
212
- function expectInvalidInput(result: RecordUsageResult) {
213
- expect(result.ok).toBe(false);
214
- const err = result as Extract<RecordUsageResult, { ok: false }>;
215
- expect(err.error.kind).toBe("invalid_input");
216
- expect(err.error.persisted).toBe(false);
217
- }
218
-
219
- describe("calculateProviderCost", () => {
220
- // ── AC-1: standard path ────────────────────────────────────────────────────
221
- it("standard path — no context window / at-or-below 200K", () => {
222
- const pricing: ModelPricing = {
223
- inputPricePerMillion: 3,
224
- outputPricePerMillion: 15,
225
- cachedInputPricePerMillion: 0.3,
226
- };
227
- const cost = calculateProviderCost(1_000_000, 500_000, 200_000, pricing);
228
- // inputCost=1*3=3, cachedInputCost=0.5*0.3=0.15, outputCost=0.2*15=3
229
- expect(cost).toBeCloseTo(6.15, 6);
230
- });
231
-
232
- it("cached input defaults to half the input rate when unset", () => {
233
- const pricing: ModelPricing = { inputPricePerMillion: 4, outputPricePerMillion: 20 };
234
- const cost = calculateProviderCost(1_000_000, 1_000_000, 0, pricing);
235
- // inputCost=1*4=4, cachedInputCost=1*(4*0.5)=2
236
- expect(cost).toBeCloseTo(6, 6);
237
- });
238
-
239
- // ── AC-1: extended-context 2x split ────────────────────────────────────────
240
- it("extended-context path — only when chargeExtendedAt2x AND contextWindowSize>200_000", () => {
241
- const pricing: ModelPricing = {
242
- inputPricePerMillion: 1,
243
- outputPricePerMillion: 1,
244
- cachedInputPricePerMillion: 1,
245
- };
246
- // 800K context: standardRatio=200K/800K=0.25, extendedRatio=0.75
247
- // factor = 0.25*1 + 0.75*2 = 1.75
248
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, true);
249
- expect(cost).toBeCloseTo(1.75, 6);
250
- });
251
-
252
- it("chargeExtendedAt2x=false skips the surcharge regardless of contextWindowSize", () => {
253
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
254
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, false);
255
- expect(cost).toBeCloseTo(1, 6);
256
- });
257
-
258
- it("contextWindowSize <= 200_000 skips the surcharge even when chargeExtendedAt2x=true", () => {
259
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
260
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 200_000, true);
261
- expect(cost).toBeCloseTo(1, 6);
262
- });
263
-
264
- it("contextWindowSize omitted defaults to 0 — extended pricing skipped (don't guess)", () => {
265
- const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
266
- const cost = calculateProviderCost(1_000_000, 0, 0, pricing, undefined, true);
267
- expect(cost).toBeCloseTo(1, 6);
268
- });
269
- });
270
-
271
- describe("recordUsageEvent", () => {
272
- beforeEach(() => {
273
- jest.clearAllMocks();
274
- jest.spyOn(console, "warn").mockImplementation(() => undefined);
275
- jest.spyOn(console, "error").mockImplementation(() => undefined);
276
- committedEvents = [];
277
- installAtomicDb();
278
- seedModel();
279
- seedPricing();
280
- seedMarkupTable([]);
281
- });
282
-
283
- afterEach(() => {
284
- jest.restoreAllMocks();
285
- });
286
-
287
- // ── AC-3 (AC-UB-02 service-level): write UsageEvent then upsert UsagePeriod ─
288
- it("AC-3: writes the UsageEvent (grain + costs + attribution=0) then upserts the UsagePeriod", async () => {
289
- seedModel({ tier: "PREMIUM" });
290
- seedPricing({ inputPricePerMillion: 3, outputPricePerMillion: 15 });
291
-
292
- const result = await recordUsageEvent(baseInput());
293
-
294
- // providerCost = 1M/1e6 * 3 = 3; billedCost = 3 * 1.2 = 3.6
295
- expect(result.ok).toBe(true);
296
- const okResult = result as Extract<RecordUsageResult, { ok: true }>;
297
- expect(okResult.eventId).toBe("evt_1");
298
- expect(okResult.billedCostUsd).toBeCloseTo(3.6, 6);
299
-
300
- expect(createdEventData()).toEqual(
301
- expect.objectContaining({
302
- organizationId: ORG_ID,
303
- workspaceId: WORKSPACE_ID,
304
- userId: USER_ID,
305
- provider: PROVIDER,
306
- model: MODEL,
307
- providerCostUsd: 3,
308
- markupPercent: 20,
309
- billedCostUsd: expect.closeTo(3.6, 6),
310
- modelTier: "PREMIUM",
311
- wasAutoModel: false,
312
- includedPortionCents: 0,
313
- creditBalancePortionCents: 0,
314
- onDemandPortionCents: 0,
315
- completionStatus: "success",
316
- }),
317
- );
318
-
319
- // H1/H2 (per-event ceil to whole cents) are DELIBERATELY unchanged — this
320
- // 360¢ expectation encodes the current, spec-mandated rounding, pending Sam's
321
- // billing-fairness decision.
322
- expect(upsertArgs().create).toEqual(
323
- expect.objectContaining({
324
- organizationId: ORG_ID,
325
- tokensConsumed: 1_000_000,
326
- usageSpendCents: 360, // ceil(3.6 * 100)
327
- premiumTokensConsumed: 1_000_000,
328
- }),
329
- );
330
- expect(upsertArgs().update).toEqual(
331
- expect.objectContaining({
332
- tokensConsumed: { increment: 1_000_000 },
333
- usageSpendCents: { increment: 360 },
334
- premiumTokensConsumed: { increment: 1_000_000 },
335
- }),
336
- );
337
- });
338
-
339
- // ── Tier-column selection (BASE / FRONTIER) ────────────────────────────────
340
- it.each([
341
- ["BASE", "baseTokensConsumed"],
342
- ["FRONTIER", "frontierTokensConsumed"],
343
- ] as const)("upserts the %s tier column (%s)", async (tier, column) => {
344
- seedModel({ tier });
345
-
346
- await recordUsageEvent(baseInput());
347
-
348
- expect(upsertArgs().create).toEqual(
349
- expect.objectContaining({ [column]: 1_000_000 }),
350
- );
351
- expect(upsertArgs().update).toEqual(
352
- expect.objectContaining({ [column]: { increment: 1_000_000 } }),
353
- );
354
- });
355
-
356
- // ── AC-2: markup hierarchy ─────────────────────────────────────────────────
357
- it("AC-2: markup resolves the model-specific row first", async () => {
358
- seedMarkupTable([
359
- { provider: PROVIDER, model: MODEL, markupPercent: 5 },
360
- { provider: PROVIDER, model: null, markupPercent: 10 },
361
- { provider: null, model: null, markupPercent: 8 },
362
- ]);
363
-
364
- const result = (await recordUsageEvent(baseInput())) as Extract<
365
- RecordUsageResult,
366
- { ok: true }
367
- >;
368
-
369
- expect(result.billedCostUsd).toBeCloseTo(1.05, 6); // providerCost=1 * (1+5/100)
370
- });
371
-
372
- it("AC-2: markup falls back to the provider-wide row", async () => {
373
- seedMarkupTable([
374
- { provider: PROVIDER, model: null, markupPercent: 10 },
375
- { provider: null, model: null, markupPercent: 8 },
376
- ]);
377
-
378
- const result = (await recordUsageEvent(baseInput())) as Extract<
379
- RecordUsageResult,
380
- { ok: true }
381
- >;
382
-
383
- expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
384
- });
385
-
386
- it("AC-2: markup falls back to the organization-default row", async () => {
387
- seedMarkupTable([{ provider: null, model: null, markupPercent: 8 }]);
388
-
389
- const result = (await recordUsageEvent(baseInput())) as Extract<
390
- RecordUsageResult,
391
- { ok: true }
392
- >;
393
-
394
- expect(result.billedCostUsd).toBeCloseTo(1.08, 6);
395
- });
396
-
397
- it("AC-2: markup falls back to the 20% system default", async () => {
398
- seedMarkupTable([]);
399
-
400
- const result = (await recordUsageEvent(baseInput())) as Extract<
401
- RecordUsageResult,
402
- { ok: true }
403
- >;
404
-
405
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
406
- });
407
-
408
- // ── Unknown model → safe defaults, no crash ────────────────────────────────
409
- it("an unresolvable model defaults to BASE / non-auto / no extended surcharge", async () => {
410
- mockOf(db.llmModel.findFirst).mockResolvedValue(null);
411
-
412
- // A large context window would trigger the surcharge IF chargeExtendedAt2x
413
- // resolved true — for an unknown model it must default false.
414
- const result = (await recordUsageEvent(
415
- baseInput({ contextWindowSize: 800_000 }),
416
- )) as Extract<RecordUsageResult, { ok: true }>;
417
-
418
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6); // no 2x surcharge
419
- expect(createdEventData()).toEqual(
420
- expect.objectContaining({ modelTier: "BASE", wasAutoModel: false }),
421
- );
422
- });
423
-
424
- // ── PII non-leak — the result never carries token content ──────────────────
425
- it("PII/content non-leak — the result carries only ids and computed costs", async () => {
426
- const result = await recordUsageEvent(baseInput());
427
-
428
- expect(Object.keys(result)).toEqual(
429
- expect.arrayContaining(["ok", "eventId", "billedCostUsd"]),
430
- );
431
- expect(JSON.stringify(result).length).toBeLessThan(200);
432
- });
433
-
434
- // ══ H3 — atomicity: the event + the aggregate are ONE transaction ══════════
435
- describe("H3 — atomic write (no double-billing on retry)", () => {
436
- it("a failed UsagePeriod upsert persists NO UsageEvent — a retry cannot double-bill", async () => {
437
- // The P2002 upsert race: two concurrent calls create the same
438
- // (organizationId, periodStart) row; the loser throws.
439
- installAtomicDb({
440
- upsertError: new Error(
441
- "Unique constraint failed on the fields: (`organizationId`,`periodStart`)",
442
- ),
443
- });
444
-
445
- const result = await recordUsageEvent(baseInput());
446
-
447
- expect(result.ok).toBe(false);
448
- // THE assertion: a non-transactional implementation commits the event
449
- // before the upsert runs, stranding an orphan row here — and the caller's
450
- // retry then writes a SECOND event, billing the org twice for one AI call.
451
- expect(committedEvents).toHaveLength(0);
452
- });
453
-
454
- it("a write failure reports persisted:false — a retry is always safe", async () => {
455
- installAtomicDb({ upsertError: new Error("connection reset") });
456
-
457
- const result = await recordUsageEvent(baseInput());
458
-
459
- const err = result as Extract<RecordUsageResult, { ok: false }>;
460
- expect(err.error.kind).toBe("write_failed");
461
- expect(err.error.persisted).toBe(false);
462
- });
463
-
464
- it("both writes go through $transaction — neither is reachable off the base client", async () => {
465
- // A transaction that cannot even be opened must produce ZERO writes:
466
- // proof that the writes live only inside the transaction callback.
467
- mockOf(db.$transaction).mockImplementation(async () => {
468
- throw new Error("could not start transaction");
469
- });
470
-
471
- const result = await recordUsageEvent(baseInput());
472
-
473
- expect(result.ok).toBe(false);
474
- expect(db.usageEvent.create).not.toHaveBeenCalled();
475
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
476
- expect(committedEvents).toHaveLength(0);
477
- expect(db.$transaction).toHaveBeenCalledTimes(1);
478
- });
479
-
480
- it("the happy path commits exactly one event, through the transaction", async () => {
481
- const result = await recordUsageEvent(baseInput());
482
-
483
- expect(result.ok).toBe(true);
484
- expect(db.$transaction).toHaveBeenCalledTimes(1);
485
- expect(committedEvents).toHaveLength(1);
486
- });
487
- });
488
-
489
- // ══ H4 — boundary validation: no negative/NaN/Infinity reaches cost or DB ══
490
- describe("H4 — input validation (invalid_input)", () => {
491
- it("a NEGATIVE token count is rejected — it cannot decrement the org's spend", async () => {
492
- const result = await recordUsageEvent(
493
- baseInput({ inputTokensNoCacheWrite: -1_000_000 }),
494
- );
495
-
496
- expectInvalidInput(result);
497
- // The attack: a negative cost → a negative usageSpendCents increment →
498
- // the org's metered spend is DECREMENTED (its bill zeroed out).
499
- expect(db.$transaction).not.toHaveBeenCalled();
500
- expect(db.usageEvent.create).not.toHaveBeenCalled();
501
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
502
- expect(committedEvents).toHaveLength(0);
503
- });
504
-
505
- it("NaN is rejected — Postgres double precision accepts it and the aggregate is poisoned forever", async () => {
506
- const result = await recordUsageEvent(baseInput({ outputTokens: Number.NaN }));
507
-
508
- expectInvalidInput(result);
509
- expect(db.usageEvent.create).not.toHaveBeenCalled();
510
- expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
511
- });
512
-
513
- it("Infinity is rejected", async () => {
514
- const result = await recordUsageEvent(
515
- baseInput({ outputTokens: Number.POSITIVE_INFINITY }),
516
- );
517
-
518
- expectInvalidInput(result);
519
- expect(db.usageEvent.create).not.toHaveBeenCalled();
520
- });
521
-
522
- it("a non-integer token count is rejected", async () => {
523
- const result = await recordUsageEvent(baseInput({ outputTokens: 1.5 }));
524
-
525
- expectInvalidInput(result);
526
- expect(db.usageEvent.create).not.toHaveBeenCalled();
527
- });
528
-
529
- it("an absurd token magnitude is rejected (overflow guard)", async () => {
530
- const result = await recordUsageEvent(
531
- baseInput({ inputTokensNoCacheWrite: 1e15 }),
532
- );
533
-
534
- expectInvalidInput(result);
535
- expect(db.usageEvent.create).not.toHaveBeenCalled();
536
- });
537
-
538
- it("a negative contextWindowSize is rejected", async () => {
539
- const result = await recordUsageEvent(baseInput({ contextWindowSize: -1 }));
540
-
541
- expectInvalidInput(result);
542
- expect(db.usageEvent.create).not.toHaveBeenCalled();
543
- });
544
-
545
- it("the error names the offending field", async () => {
546
- const result = await recordUsageEvent(baseInput({ outputTokens: -5 }));
547
-
548
- const err = result as Extract<RecordUsageResult, { ok: false }>;
549
- expect(err.error.message).toContain("outputTokens");
550
- });
551
- });
552
-
553
- // ══ H5 — tenant grain: branded ids, validated at the boundary ══════════════
554
- describe("H5 — tenant grain", () => {
555
- it("rejects a MISMATCHED grain — a workspace id smuggled into the organization slot", async () => {
556
- // organizationId keys BOTH the markup lookup and the spend increment, so a
557
- // wrong id here bills the wrong tenant (and leaks its markup back).
558
- const badTenant = forgedTenant({ organizationId: WORKSPACE_ID });
559
-
560
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
561
-
562
- expectInvalidInput(result);
563
- expect(db.$transaction).not.toHaveBeenCalled();
564
- expect(db.aiMarkupConfig.findFirst).not.toHaveBeenCalled();
565
- });
566
-
567
- it("rejects an arbitrary caller-supplied string as an organization id", async () => {
568
- const badTenant = forgedTenant({ organizationId: "victim-org" });
569
-
570
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
571
-
572
- expectInvalidInput(result);
573
- expect(db.usageEvent.create).not.toHaveBeenCalled();
574
- });
575
-
576
- it("rejects a malformed workspace id", async () => {
577
- const badTenant = forgedTenant({ workspaceId: "wksp_not-a-uuid" });
578
-
579
- const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
580
-
581
- expectInvalidInput(result);
582
- expect(db.usageEvent.create).not.toHaveBeenCalled();
583
- });
584
-
585
- it("persists the tenant grain exactly as resolved", async () => {
586
- await recordUsageEvent(baseInput());
587
-
588
- expect(createdEventData()).toEqual(
589
- expect.objectContaining({
590
- organizationId: ORG_ID,
591
- workspaceId: WORKSPACE_ID,
592
- userId: USER_ID,
593
- }),
594
- );
595
- });
596
- });
597
-
598
- // ══ M1 — totalTokens is DERIVED, never caller-supplied ═════════════════════
599
- describe("M1 — totalTokens derived server-side", () => {
600
- it("derives totalTokens from the buckets — a caller cannot bill without burning quota", async () => {
601
- await recordUsageEvent(
602
- baseInput({
603
- inputTokensCacheWrite: 1_000,
604
- inputTokensNoCacheWrite: 10_000,
605
- cacheReadTokens: 2_000,
606
- outputTokens: 500,
607
- thinkingTokens: 100,
608
- }),
609
- );
610
-
611
- const total = 1_000 + 10_000 + 2_000 + 500 + 100; // 13_600
612
- expect(createdEventData()).toEqual(
613
- expect.objectContaining({ totalTokens: total }),
614
- );
615
- // The quota surface: tokensConsumed + the tier column track the derived sum.
616
- expect(upsertArgs().create).toEqual(
617
- expect.objectContaining({
618
- tokensConsumed: total,
619
- baseTokensConsumed: total,
620
- }),
621
- );
622
- expect(upsertArgs().update).toEqual(
623
- expect.objectContaining({
624
- tokensConsumed: { increment: total },
625
- baseTokensConsumed: { increment: total },
626
- }),
627
- );
628
- });
629
- });
630
-
631
- // ══ M2 — markup + pricing range validation ═════════════════════════════════
632
- describe("M2 — markup/pricing range validation", () => {
633
- it("a NULL markup column does not become a silent 0% (lost revenue) — falls through to 20%", async () => {
634
- seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: null }]);
635
-
636
- const result = (await recordUsageEvent(baseInput())) as Extract<
637
- RecordUsageResult,
638
- { ok: true }
639
- >;
640
-
641
- // Number(null) === 0 would bill at cost. The system default must win.
642
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
643
- expect(createdEventData()).toEqual(
644
- expect.objectContaining({ markupPercent: 20 }),
645
- );
646
- });
647
-
648
- it("a NEGATIVE markup row never bills below provider cost", async () => {
649
- seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: -50 }]);
650
-
651
- const result = (await recordUsageEvent(baseInput())) as Extract<
652
- RecordUsageResult,
653
- { ok: true }
654
- >;
655
-
656
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
657
- });
658
-
659
- it("a non-numeric markup row falls through to the default", async () => {
660
- seedMarkupTable([
661
- { provider: PROVIDER, model: MODEL, markupPercent: "not-a-number" },
662
- ]);
663
-
664
- const result = (await recordUsageEvent(baseInput())) as Extract<
665
- RecordUsageResult,
666
- { ok: true }
667
- >;
668
-
669
- expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
670
- expect(Number.isFinite(createdEventData().markupPercent as number)).toBe(true);
671
- });
672
-
673
- it("an unusable model-specific row falls through the chain to the provider-wide row", async () => {
674
- seedMarkupTable([
675
- { provider: PROVIDER, model: MODEL, markupPercent: null },
676
- { provider: PROVIDER, model: null, markupPercent: 10 },
677
- ]);
678
-
679
- const result = (await recordUsageEvent(baseInput())) as Extract<
680
- RecordUsageResult,
681
- { ok: true }
682
- >;
683
-
684
- expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
685
- });
686
-
687
- it("a corrupt pricing rate never reaches the cost math (no NaN in the DB)", async () => {
688
- seedPricing({ inputPricePerMillion: "corrupt" });
689
-
690
- const result = (await recordUsageEvent(baseInput())) as Extract<
691
- RecordUsageResult,
692
- { ok: true }
693
- >;
694
-
695
- expect(result.billedCostUsd).toBe(0);
696
- expect(Number.isFinite(createdEventData().providerCostUsd as number)).toBe(true);
697
- expect(Number.isFinite(upsertArgs().create.usageSpendCents as number)).toBe(true);
698
- });
699
- });
700
-
701
- // ══ M3 — unvalidated tier → an "undefined" column key ══════════════════════
702
- describe("M3 — tier validation", () => {
703
- it("an out-of-union tier defaults to BASE — never an 'undefined' upsert key", async () => {
704
- seedModel({ tier: "LEGENDARY" }); // a tier the catalog should never hold
705
-
706
- const result = await recordUsageEvent(baseInput());
707
-
708
- expect(result.ok).toBe(true);
709
- expect(upsertArgs().create).toEqual(
710
- expect.objectContaining({ baseTokensConsumed: 1_000_000 }),
711
- );
712
- expect(upsertArgs().create).not.toHaveProperty("undefined");
713
- expect(upsertArgs().update).not.toHaveProperty("undefined");
714
- expect(createdEventData()).toEqual(
715
- expect.objectContaining({ modelTier: "BASE" }),
716
- );
717
- });
718
- });
719
-
720
- // ══ M4 — missing pricing is MARKED, not silent ═════════════════════════════
721
- describe("M4 — missing pricing", () => {
722
- it("meters at $0 without blocking, and MARKS the event so it can be re-priced", async () => {
723
- mockOf(db.llmModelPricing.findFirst).mockResolvedValue(null);
724
-
725
- const result = await recordUsageEvent(baseInput());
726
-
727
- expect(result).toEqual<RecordUsageResult>({
728
- ok: true,
729
- eventId: "evt_1",
730
- billedCostUsd: 0,
731
- });
732
- const data = createdEventData();
733
- expect(data).toEqual(
734
- expect.objectContaining({ providerCostUsd: 0, billedCostUsd: 0 }),
735
- );
736
- // Distinguishable from a genuine $0 call — and the caller's status survives.
737
- expect(data.completionStatus).toBe("pricing_missing:success");
738
- });
739
-
740
- it("a priced call carries the caller's status unmarked", async () => {
741
- await recordUsageEvent(baseInput({ completionStatus: "error" }));
742
-
743
- expect(createdEventData().completionStatus).toBe("error");
744
- });
745
- });
746
-
747
- // ══ M5 — raw DB error text is never returned ═══════════════════════════════
748
- describe("M5 — error propagation", () => {
749
- it("does not leak the raw Prisma message (which embeds ids + token counts)", async () => {
750
- // A Prisma validation error embeds the whole `data` argument.
751
- const leaky = new Error(
752
- `Invalid \`prisma.usageEvent.create()\` invocation: { organizationId: "${ORG_ID}", userId: "${USER_ID}", inputTokensNoCacheWrite: 1000000 }`,
753
- );
754
- installAtomicDb({ upsertError: leaky });
755
-
756
- const result = await recordUsageEvent(baseInput());
757
-
758
- const err = result as Extract<RecordUsageResult, { ok: false }>;
759
- expect(err.error.kind).toBe("write_failed");
760
- expect(err.error.message).toBe("usage event write failed");
761
- expect(err.error.message).not.toContain(ORG_ID);
762
- expect(err.error.message).not.toContain(USER_ID);
763
- expect(err.error.message).not.toContain("prisma");
764
- // The detail is still available to operators — server-side only.
765
- expect(console.error).toHaveBeenCalled();
766
- });
767
- });
768
- });