create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -35,7 +35,7 @@ jest.mock("@working-theory/license", () => ({
35
35
  jest.mock("../../lib/prisma.js", () => ({
36
36
  prisma: {
37
37
  projectListing: { findMany: jest.fn() },
38
- waitlistScore: { count: jest.fn() },
38
+ waitlistScore: { groupBy: jest.fn() },
39
39
  },
40
40
  }));
41
41
 
@@ -102,25 +102,25 @@ function listingRow(id: string, experimentKey: string | null): Record<string, un
102
102
 
103
103
  beforeEach(() => {
104
104
  jest.clearAllMocks();
105
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(3);
105
+ (prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
106
106
  });
107
107
 
108
108
  describe("attachWaitlistCounts — the fenced count filters on workspaceId beside siteId (#6258 AC-6)", () => {
109
- it("prisma.waitlistScore.count's where carries workspaceId === the resolved scope id, by value", async () => {
109
+ it("prisma.waitlistScore.groupBy's where carries workspaceId === the resolved scope id, by value", async () => {
110
110
  (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
111
111
  listingRow("plst_a", EXPERIMENT_A),
112
112
  ]);
113
113
 
114
114
  await listProjectListings(SITE);
115
115
 
116
- expect((prisma.waitlistScore.count as jest.Mock).mock.calls[0]![0]!.where).toEqual({
116
+ expect((prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where).toEqual({
117
117
  siteId: SITE,
118
118
  workspaceId: SITE,
119
- experimentKey: EXPERIMENT_A,
119
+ experimentKey: { in: [EXPERIMENT_A] },
120
120
  });
121
121
  });
122
122
 
123
- it("every DISTINCT experimentKey gets the same two scope terms — one count per key, no more", async () => {
123
+ it("every DISTINCT experimentKey sits under the same two scope terms — one grouped read, no more (#9243)", async () => {
124
124
  (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
125
125
  listingRow("plst_a", EXPERIMENT_A),
126
126
  listingRow("plst_b", EXPERIMENT_B),
@@ -129,17 +129,14 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
129
129
 
130
130
  await listProjectListings(SITE);
131
131
 
132
- expect(prisma.waitlistScore.count).toHaveBeenCalledTimes(2);
133
- const wheres = (prisma.waitlistScore.count as jest.Mock).mock.calls.map(
134
- (call) => call[0]!.where as Record<string, unknown>,
135
- );
136
- for (const where of wheres) {
137
- expect(where.siteId).toBe(SITE);
138
- expect(where.workspaceId).toBe(SITE);
139
- }
140
- expect(wheres.map((w) => w.experimentKey).sort()).toEqual(
141
- [EXPERIMENT_A, EXPERIMENT_B].sort(),
142
- );
132
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
133
+ const where = (prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where as Record<
134
+ string,
135
+ unknown
136
+ >;
137
+ expect(where.siteId).toBe(SITE);
138
+ expect(where.workspaceId).toBe(SITE);
139
+ expect(where.experimentKey).toEqual({ in: [EXPERIMENT_A, EXPERIMENT_B] });
143
140
  });
144
141
 
145
142
  it("no listing carries an experimentKey → no fenced read is issued at all", async () => {
@@ -149,6 +146,6 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
149
146
 
150
147
  await listProjectListings(SITE);
151
148
 
152
- expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
149
+ expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
153
150
  });
154
151
  });
@@ -134,6 +134,16 @@ function buildResumeLink(
134
134
  return `${resumeUrl}?fs=${sessionId}`;
135
135
  }
136
136
 
137
+ // ─── Candidate paging (#9249) ───────────────────────────────────────────────────
138
+
139
+ /**
140
+ * How many candidates one run reads per flow definition. A flow with more
141
+ * past-delay sessions than this processes one page and leaves the rest —
142
+ * still `abandonEmailStatus: null` — for the next run. Same page as
143
+ * `STANDARD_SEED_PAGE` (lib/tenant-db.ts), this app's other background page.
144
+ */
145
+ export const ABANDON_CANDIDATE_PAGE_SIZE = 500;
146
+
137
147
  // ─── The sweep ──────────────────────────────────────────────────────────────────
138
148
 
139
149
  /**
@@ -197,19 +207,21 @@ async function sweepAllDefinitions(
197
207
  email: true,
198
208
  locale: true,
199
209
  },
210
+ // #9249 — oldest-updated first, so the page is deterministic (the same
211
+ // reason flow-aggregate.ts orders its capped read).
212
+ orderBy: { updatedAt: "asc" },
213
+ take: ABANDON_CANDIDATE_PAGE_SIZE,
200
214
  })) as AbandonCandidateRow[];
201
215
 
202
216
  summary.scanned += candidates.length;
203
217
 
218
+ const darkIds: string[] = [];
219
+
204
220
  for (const session of candidates) {
205
221
  summary.selected += 1;
206
222
 
207
223
  if (policy.mode === "dark") {
208
- await prisma.flowSession.update({
209
- where: { id: session.id },
210
- data: { abandonEmailStatus: "dark", abandonEmailAt: now },
211
- });
212
- summary.dark += 1;
224
+ darkIds.push(session.id);
213
225
  logOutcome(session, policy.mode, "dark");
214
226
  continue;
215
227
  }
@@ -265,6 +277,17 @@ async function sweepAllDefinitions(
265
277
  logOutcome(session, policy.mode, "failed");
266
278
  }
267
279
  }
280
+
281
+ // #9249 — one stamp for the whole dark-mode page, not one per row. The
282
+ // `abandonEmailStatus: null` guard is the candidate read's own term, so a
283
+ // row an overlapping run stamped meanwhile keeps what that run wrote.
284
+ if (darkIds.length > 0) {
285
+ await prisma.flowSession.updateMany({
286
+ where: { id: { in: darkIds }, abandonEmailStatus: null },
287
+ data: { abandonEmailStatus: "dark", abandonEmailAt: now },
288
+ });
289
+ summary.dark += darkIds.length;
290
+ }
268
291
  }
269
292
  }
270
293
 
@@ -84,12 +84,6 @@ export type FulfillDeliverableInput = {
84
84
  locale: string;
85
85
  };
86
86
 
87
- type DeliverableAssetRow = {
88
- id: string;
89
- siteId: string;
90
- key: string;
91
- };
92
-
93
87
  // Derived from the schema type (not hand-rolled) so it never drifts from
94
88
  // `deliverableAssetMapEntrySchema`'s actual Zod-inferred optional shape.
95
89
  type DeliverableEntry = NonNullable<
@@ -387,7 +381,7 @@ export async function sendCompletionEmail(
387
381
 
388
382
  export async function fulfillDeliverable(
389
383
  input: FulfillDeliverableInput,
390
- ): Promise<void> {
384
+ ): Promise<{ grantId: string } | undefined> {
391
385
  const {
392
386
  siteId,
393
387
  flowSlug,
@@ -403,7 +397,7 @@ export async function fulfillDeliverable(
403
397
  // the call on this being present; re-checking here is defense in depth,
404
398
  // never load-bearing for the "no block → no call" AC4 regression floor.
405
399
  const block = configSnapshot.deliverables;
406
- if (!block) return;
400
+ if (!block) return undefined;
407
401
 
408
402
  const logCtx = { siteId, flowSlug, sessionId };
409
403
 
@@ -412,7 +406,7 @@ export async function fulfillDeliverable(
412
406
  const entry = resolveEntry(block.assets, segment);
413
407
  if (!entry) {
414
408
  logOutcome(logCtx, "no_entry");
415
- return;
409
+ return undefined;
416
410
  }
417
411
 
418
412
  // FEAT-074 U4b (#4718) D4 — the `playbook:` virtual asset: a per-lead
@@ -439,7 +433,7 @@ export async function fulfillDeliverable(
439
433
  });
440
434
  if (!instance) {
441
435
  logOutcome(logCtx, "playbook_missing");
442
- return;
436
+ return undefined;
443
437
  }
444
438
  assetId = entry.assetKey;
445
439
  } else {
@@ -464,6 +458,13 @@ export async function fulfillDeliverable(
464
458
  assetId = resolvedAssetId;
465
459
  grantWorkspaceId = workspaceId;
466
460
  } else {
461
+ // #8848 D3 (SEC-8848-B) — a `deliverable` flow resolves against the
462
+ // Asset store only: it is new, so it has no legacy key to keep, and the
463
+ // legacy store also holds the founders' signup CSVs.
464
+ if (configSnapshot.deliverable !== undefined) {
465
+ logOutcome(logCtx, "asset_missing");
466
+ return undefined;
467
+ }
467
468
  // #6310 D2/D3 — legacy fallback. A missing row (config references an
468
469
  // un-uploaded key) → same skip-and-log posture as before.
469
470
  // #6257 — the LEGACY store's read, scoped by the same resolved
@@ -472,11 +473,12 @@ export async function fulfillDeliverable(
472
473
  // one row; `workspaceId` is what makes the tenant the QUERY's statement.
473
474
  const asset = (await prisma.deliverableAsset.findUnique({
474
475
  where: { siteId_key: { siteId, key: entry.assetKey }, workspaceId: siteId },
475
- })) as DeliverableAssetRow | null;
476
+ select: { id: true },
477
+ })) as { id: string } | null;
476
478
 
477
479
  if (!asset) {
478
480
  logOutcome(logCtx, "asset_missing");
479
- return;
481
+ return undefined;
480
482
  }
481
483
  assetId = asset.id;
482
484
  }
@@ -527,6 +529,7 @@ export async function fulfillDeliverable(
527
529
  defaultLocale: configSnapshot.defaultLocale,
528
530
  copy: block.copy,
529
531
  });
532
+ return { grantId: grant.id };
530
533
  }
531
534
 
532
535
  // ─── #3998 D3 — resend: reuses the existing grant id, never mints ───────────────
@@ -15,15 +15,24 @@
15
15
  * foundation fills `features` + `limits.static` (tier + seat count) and returns
16
16
  * `limits.metered` as the reserved empty shape FEAT-047 (#3679) fills — this
17
17
  * spec builds NO metering engine (spec D5).
18
+ *
19
+ * #9067 D5 — the Billing tab's plan card also reads here: the licence expiry,
20
+ * the Organization's one open plan Checkout, and the customer's billing
21
+ * summary. That summary is the one Stripe read this resolver makes, through
22
+ * `lib/stripe.ts`.
18
23
  */
19
- import type { Entitlements } from "@working-theory/license";
24
+ import { checkLicense, type Entitlements } from "@working-theory/license";
25
+ import { logger } from "@working-theory/observability";
20
26
  import {
21
27
  MAGNET_KIND_MIN_RANK,
28
+ orgBillingDetailsSchema,
22
29
  planRank,
23
30
  type FlowConfig,
31
+ type OrgBillingDetails,
24
32
  } from "@working-theory/validation";
25
33
 
26
34
  import { prisma } from "../lib/prisma.js";
35
+ import { readCustomerBillingSummary } from "../lib/stripe.js";
27
36
 
28
37
  /**
29
38
  * Feature flags unlocked per tier. Free unlocks none; every paid tier unlocks
@@ -180,3 +189,150 @@ export async function isBillingCustomerAnOrganization(
180
189
  });
181
190
  return org !== null;
182
191
  }
192
+
193
+ /**
194
+ * #9067 D5 — the deployment licence's expiry, as an ISO string, or `null`
195
+ * for a keyless, expired or invalid licence. The trial state's date
196
+ * (C2). Never throws: a failed read is `null`.
197
+ */
198
+ export async function resolveLicenseExpiresAt(): Promise<string | null> {
199
+ try {
200
+ const license = await checkLicense();
201
+ return license.expiresAt?.toISOString() ?? null;
202
+ } catch {
203
+ return null;
204
+ }
205
+ }
206
+
207
+ /**
208
+ * #9067 D13 — the one plan Checkout this Organization has open, or `null`.
209
+ * Server-side only: the session id is never served. An unknown
210
+ * Organization reads `null`.
211
+ */
212
+ export async function readOrgPlanCheckoutSession(
213
+ organizationId: string,
214
+ ): Promise<string | null> {
215
+ const org = await prisma.organization.findUnique({
216
+ where: { id: organizationId },
217
+ select: { planCheckoutSessionId: true },
218
+ });
219
+ return org?.planCheckoutSessionId ?? null;
220
+ }
221
+
222
+ /**
223
+ * #9067 D3 step 9b — record the plan Checkout the route just opened, as one
224
+ * compare-and-set: only while the column still holds `expected` and no
225
+ * customer is bound. Answers whether one row moved; throws on a database
226
+ * error.
227
+ */
228
+ export async function setOrgPlanCheckoutSession(
229
+ organizationId: string,
230
+ expected: string | null,
231
+ next: string,
232
+ ): Promise<boolean> {
233
+ const { count } = await prisma.organization.updateMany({
234
+ where: { id: organizationId, planCheckoutSessionId: expected, billingCustomerId: null },
235
+ data: { planCheckoutSessionId: next },
236
+ });
237
+ return count === 1;
238
+ }
239
+
240
+ /**
241
+ * #9067 7b (code review) — bind the Organization to the customer a COMPLETED
242
+ * plan Checkout created, when the webhook's bind never landed (a lost event,
243
+ * a database error the webhook swallowed). The same guarded compare-and-set
244
+ * the webhook runs: only while the column still holds `sessionId` and no
245
+ * customer is bound, and it clears the session. Answers whether one row
246
+ * moved; throws on a database error.
247
+ */
248
+ export async function bindOrgPlanCheckout(
249
+ organizationId: string,
250
+ sessionId: string,
251
+ customerId: string,
252
+ ): Promise<boolean> {
253
+ const { count } = await prisma.organization.updateMany({
254
+ where: { id: organizationId, planCheckoutSessionId: sessionId, billingCustomerId: null },
255
+ data: { billingProvider: "stripe", billingCustomerId: customerId, planCheckoutSessionId: null },
256
+ });
257
+ return count === 1;
258
+ }
259
+
260
+ /** How long one customer's summary is reused, a failure included (#9067 NFR-1). */
261
+ export const ORG_BILLING_SUMMARY_TTL_MS = 60_000;
262
+ /** The most customers held at once; the oldest entry is evicted at the cap. */
263
+ export const ORG_BILLING_SUMMARY_CACHE_MAX = 2_000;
264
+ /** The longest one summary read may take before the keys are left absent (NFR-2). */
265
+ export const ORG_BILLING_SUMMARY_TIMEOUT_MS = 3_000;
266
+
267
+ type OrgBillingSummaryKeys = Partial<Pick<OrgBillingDetails, "renewsAt" | "sites" | "paymentMethod">>;
268
+
269
+ /** Per customer: the settled keys and when they expire. Insertion order is age. */
270
+ const orgBillingSummaryCache = new Map<string, { keys: OrgBillingSummaryKeys; expiresAt: number }>();
271
+ /** Per customer: the one read in flight, joined by every concurrent miss. */
272
+ const orgBillingSummaryInFlight = new Map<string, Promise<OrgBillingSummaryKeys>>();
273
+
274
+ /**
275
+ * #9067 D5 — the Organization customer's renewal date, site count and card,
276
+ * from the cache when fresh, else one read shared by every concurrent caller
277
+ * of the same customer. The member read's wrapper (`services/subscription.ts`),
278
+ * rebuilt here because importing it would be a cycle. A throw or a timeout
279
+ * answers `{}` (every key absent) and is cached like a success; a value
280
+ * outside `orgBillingDetailsSchema`'s bounds is `null`.
281
+ */
282
+ export async function readOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
283
+ const cached = orgBillingSummaryCache.get(customerId);
284
+ if (cached && cached.expiresAt > Date.now()) return cached.keys;
285
+ const pending = orgBillingSummaryInFlight.get(customerId);
286
+ if (pending) return pending;
287
+
288
+ const read = settleOrgBillingSummary(customerId).then((keys) => {
289
+ orgBillingSummaryCache.delete(customerId);
290
+ if (orgBillingSummaryCache.size >= ORG_BILLING_SUMMARY_CACHE_MAX) {
291
+ const oldest = orgBillingSummaryCache.keys().next().value;
292
+ if (oldest !== undefined) orgBillingSummaryCache.delete(oldest);
293
+ }
294
+ orgBillingSummaryCache.set(customerId, {
295
+ keys,
296
+ expiresAt: Date.now() + ORG_BILLING_SUMMARY_TTL_MS,
297
+ });
298
+ return keys;
299
+ });
300
+ // Cleared however the read ends, so a rejection is never served forever.
301
+ void read.finally(() => orgBillingSummaryInFlight.delete(customerId)).catch(() => undefined);
302
+ orgBillingSummaryInFlight.set(customerId, read);
303
+ return read;
304
+ }
305
+
306
+ async function settleOrgBillingSummary(customerId: string): Promise<OrgBillingSummaryKeys> {
307
+ let timer: ReturnType<typeof setTimeout> | undefined;
308
+ try {
309
+ const timedOut = new Promise<"timeout">((resolve) => {
310
+ timer = setTimeout(() => resolve("timeout"), ORG_BILLING_SUMMARY_TIMEOUT_MS);
311
+ });
312
+ const summary = await Promise.race([readCustomerBillingSummary(customerId), timedOut]);
313
+ if (summary === "timeout") throw new Error("timeout");
314
+ const { shape } = orgBillingDetailsSchema;
315
+ const renewsAt = shape.renewsAt.safeParse(summary.renewsAt);
316
+ const sites = shape.sites.safeParse(summary.sites);
317
+ const paymentMethod = shape.paymentMethod.safeParse(summary.paymentMethod);
318
+ return {
319
+ renewsAt: renewsAt.success ? (renewsAt.data ?? null) : null,
320
+ sites: sites.success ? (sites.data ?? null) : null,
321
+ paymentMethod: paymentMethod.success ? (paymentMethod.data ?? null) : null,
322
+ };
323
+ } catch {
324
+ logger.warn(
325
+ { route: "billing", outcome: "summary_unavailable" },
326
+ "[billing] organization billing summary unavailable",
327
+ );
328
+ return {};
329
+ } finally {
330
+ clearTimeout(timer);
331
+ }
332
+ }
333
+
334
+ /** Test seam: forget every cached and in-flight Organization summary. */
335
+ export function __resetOrgBillingSummaryCache(): void {
336
+ orgBillingSummaryCache.clear();
337
+ orgBillingSummaryInFlight.clear();
338
+ }