create-ailk 0.21.0 → 0.23.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 (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  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 +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,161 @@
1
+ /**
2
+ * @file in-page-magnets-migration.test.ts
3
+ *
4
+ * DB-free: migrations/20261022120000_in_page_magnets writes two configs as
5
+ * JSONB literals, because SQL cannot import TS (#8847 spec
6
+ * docs/specs/8847-the-in-page-form-shape-spec.md D5; AC-3, AC-5). This test
7
+ * holds each literal to its one TS source so they can never drift, as
8
+ * `member-contract-migration.test.ts` does for the request-quote backfill:
9
+ *
10
+ * - `newsletter backfill` inserts `newsletterFlowConfig` into every
11
+ * workspace, insert-only on `(site_id, slug)`;
12
+ * - `contact-form upgrade` replaces a stored Contact form with
13
+ * `contactFormFlowConfig` ONLY where its config equals #8661's shipped
14
+ * default as JSONB, so an owner's edit (or a wizard-built Contact form) is
15
+ * never touched, and it sets nothing but `config` and `updated_at`.
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import {
21
+ CONTACT_FORM_FLOW_SLUG,
22
+ contactFormFlowConfig,
23
+ flowConfigSchema,
24
+ NEWSLETTER_FLOW_SLUG,
25
+ NEWSLETTER_FLOW_TITLE,
26
+ NEWSLETTER_LANDING_PAGE,
27
+ newsletterFlowConfig,
28
+ STANDARD_MAGNET_COMPLETIONS,
29
+ } from "@working-theory/validation";
30
+
31
+ const MIGRATION_SQL = readFileSync(
32
+ join(__dirname, "..", "migrations", "20261022120000_in_page_magnets", "migration.sql"),
33
+ "utf8",
34
+ );
35
+
36
+ /**
37
+ * #8661's shipped default Contact form — `contactFormFlowConfig` as it stood
38
+ * at commit e34e7468^ (`git show e34e7468^:packages/validation/src/schemas/flow.ts`),
39
+ * the only shape the #8661 seed ever wrote. Written out here, not imported:
40
+ * the constant has moved on, and this is the value the upgrade's guard must
41
+ * match exactly.
42
+ */
43
+ const CONTACT_FORM_8661_DEFAULT = {
44
+ slug: "contact",
45
+ defaultLocale: "en",
46
+ steps: [
47
+ {
48
+ key: "contact",
49
+ type: "field",
50
+ copy: { title: { en: "Contact form" }, cta: { en: "Send message" } },
51
+ fields: [
52
+ { name: "name", inputType: "text", required: true, mapsTo: "name", copy: { label: { en: "Name" } } },
53
+ { name: "email", inputType: "email", required: true, mapsTo: "email", copy: { label: { en: "Email" } } },
54
+ {
55
+ name: "subject",
56
+ inputType: "select",
57
+ required: false,
58
+ options: [
59
+ { value: "General question", label: { en: "General question" } },
60
+ { value: "Working together", label: { en: "Working together" } },
61
+ { value: "Support", label: { en: "Support" } },
62
+ { value: "Press and partnerships", label: { en: "Press and partnerships" } },
63
+ { value: "Other", label: { en: "Other" } },
64
+ ],
65
+ copy: { label: { en: "Subject" }, placeholder: { en: "Choose a subject" } },
66
+ },
67
+ { name: "message", inputType: "textarea", required: false, copy: { label: { en: "Message" } } },
68
+ {
69
+ name: "terms",
70
+ inputType: "consent",
71
+ required: true,
72
+ purposes: ["service_terms"],
73
+ copy: {
74
+ label: {
75
+ en: "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
76
+ },
77
+ },
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ };
83
+
84
+ /** The text between `-- BEGIN <name>` and `-- END <name>`. */
85
+ function block(name: string): string {
86
+ const match = new RegExp(`-- BEGIN ${name}\\n([\\s\\S]*?)-- END ${name}`).exec(MIGRATION_SQL);
87
+ if (!match?.[1]) throw new Error(`${name} markers not found`);
88
+ return match[1];
89
+ }
90
+
91
+ /** A SQL string literal's body, `''` unescaped, parsed as JSON. */
92
+ const parseLiteral = (body: string): unknown => JSON.parse(body.replace(/''/g, "'"));
93
+
94
+ /** The block with every string literal blanked, so only SQL structure is left. */
95
+ const withoutLiterals = (sql: string): string => sql.replace(/'(?:[^']|'')*'/g, "''");
96
+
97
+ describe("the newsletter backfill (#8847 D5, AC-5)", () => {
98
+ const sql = block("newsletter backfill");
99
+
100
+ it("carries exactly one JSONB config literal, and it deep-equals newsletterFlowConfig", () => {
101
+ const literals = [...sql.matchAll(/'(\{[\s\S]*?\})'::jsonb/g)];
102
+ expect(literals).toHaveLength(1);
103
+ const parsed = parseLiteral(literals[0]![1]!);
104
+ expect(parsed).toEqual(newsletterFlowConfig);
105
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
106
+ });
107
+
108
+ it("writes the constants' slug, title and landing page, kind newsletter, live, the lead completion, into every workspace", () => {
109
+ expect(sql).toContain(
110
+ `INSERT INTO "flow_definitions" ("site_id", "workspace_id", "slug", "config", "kind", "title", "status", "customers_enabled", "completion", "landing_page", "updated_at")`,
111
+ );
112
+ expect(sql).toContain(`SELECT w."id", w."id", '${NEWSLETTER_FLOW_SLUG}',`);
113
+ expect(STANDARD_MAGNET_COMPLETIONS[NEWSLETTER_FLOW_SLUG]).toBe("lead");
114
+ expect(sql).toContain(
115
+ `'newsletter', '${NEWSLETTER_FLOW_TITLE}', 'live', false, 'lead', '${NEWSLETTER_LANDING_PAGE}', CURRENT_TIMESTAMP`,
116
+ );
117
+ expect(sql).toContain(`FROM "workspaces" w`);
118
+ });
119
+
120
+ it("ends ON CONFLICT (\"site_id\", \"slug\") DO NOTHING, so an existing row is never overwritten", () => {
121
+ expect(sql.trimEnd().endsWith(`ON CONFLICT ("site_id", "slug") DO NOTHING;`)).toBe(true);
122
+ expect(sql).not.toMatch(/DO UPDATE/i);
123
+ });
124
+ });
125
+
126
+ describe("the contact-form upgrade (#8847 D5, C5, AC-3)", () => {
127
+ const sql = block("contact-form upgrade");
128
+ const statements = withoutLiterals(sql).split(";").filter((s) => s.trim().length > 0);
129
+
130
+ it("is one UPDATE of flow_definitions", () => {
131
+ expect(statements).toHaveLength(1);
132
+ expect(statements[0]!.trim()).toMatch(/^UPDATE "flow_definitions" SET /);
133
+ });
134
+
135
+ it("its SET literal deep-equals contactFormFlowConfig", () => {
136
+ const set = /SET "config" = '(\{[\s\S]*?\})'::jsonb/.exec(sql);
137
+ expect(set?.[1]).toBeDefined();
138
+ const parsed = parseLiteral(set![1]!);
139
+ expect(parsed).toEqual(contactFormFlowConfig);
140
+ expect(flowConfigSchema.safeParse(parsed).success).toBe(true);
141
+ });
142
+
143
+ it("its WHERE names the contact slug and compares config with = against a ::jsonb literal equal to #8661's default", () => {
144
+ const where = new RegExp(
145
+ `WHERE "slug" = '${CONTACT_FORM_FLOW_SLUG}' AND "config" = '(\\{[\\s\\S]*?\\})'::jsonb;`,
146
+ ).exec(sql);
147
+ expect(where?.[1]).toBeDefined();
148
+ expect(parseLiteral(where![1]!)).toEqual(CONTACT_FORM_8661_DEFAULT);
149
+ // The guard is a different value from what it writes, so a re-run matches nothing.
150
+ expect(CONTACT_FORM_8661_DEFAULT).not.toEqual(contactFormFlowConfig);
151
+ expect(flowConfigSchema.safeParse(CONTACT_FORM_8661_DEFAULT).success).toBe(true);
152
+ });
153
+
154
+ it("sets no column besides config and updated_at", () => {
155
+ const setClause = /SET ([\s\S]*?)\s+WHERE /.exec(withoutLiterals(sql))?.[1];
156
+ expect(setClause).toBeDefined();
157
+ const columns = [...setClause!.matchAll(/"([a-z_]+)"\s*=/g)].map((m) => m[1]);
158
+ expect(columns).toEqual(["config", "updated_at"]);
159
+ expect(setClause).toContain(`"updated_at" = CURRENT_TIMESTAMP`);
160
+ });
161
+ });
@@ -0,0 +1,270 @@
1
+ /**
2
+ * @file offerings-checkout-session.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the Offerings Checkout session record's table (#8930,
5
+ * spec docs/specs/8930-the-offerings-checkout-session-record-spec.md D1, D2,
6
+ * AC-1, AC-2, AC-3; contract A11). Requires DATABASE_URL pointing at a Postgres
7
+ * with the migrations applied — SKIPS when DATABASE_URL is unset (the repo
8
+ * convention; the `apps/api Route Tests (Postgres)` workflow exercises it).
9
+ *
10
+ * Uses the `pg` driver directly, in ONE transaction that is rolled back, so no
11
+ * row outlives the suite. Each refusal runs in its own savepoint and names the
12
+ * constraint Postgres reports, so a refusal for the wrong reason fails.
13
+ */
14
+ import { idRegex } from "@working-theory/ids";
15
+ import { parseLookupKey } from "@working-theory/validation";
16
+ import { Client } from "pg";
17
+
18
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
19
+
20
+ const TABLE = "offerings_checkout_sessions";
21
+ const ORG = "org_01920000-8930-7000-8000-0000000000a1";
22
+ const WS = "wksp_01920000-8930-7000-8000-0000000000b1";
23
+
24
+ /** The one valid row every refusal changes a single column of. */
25
+ const VALID: Record<string, unknown> = {
26
+ session_id: "cs_test_a1",
27
+ workspace_id: WS,
28
+ organization_id: ORG,
29
+ price_id: "price_1Abc",
30
+ lookup_key: "growth_monthly",
31
+ mode: "test",
32
+ };
33
+
34
+ /** The eight CHECKs D2 names, and nothing else. */
35
+ const CHECKS = [
36
+ "offerings_checkout_sessions_id_format",
37
+ "offerings_checkout_sessions_session_id_format",
38
+ "offerings_checkout_sessions_workspace_id_format",
39
+ "offerings_checkout_sessions_organization_id_format",
40
+ "offerings_checkout_sessions_price_id_format",
41
+ "offerings_checkout_sessions_lookup_key_format",
42
+ "offerings_checkout_sessions_mode_domain",
43
+ "offerings_checkout_sessions_session_mode_agree",
44
+ ];
45
+
46
+ describeDb(
47
+ "the Offerings Checkout session record (#8930 D1, D2, live DB)",
48
+ () => {
49
+ let db: Client;
50
+
51
+ beforeAll(async () => {
52
+ db = new Client({ connectionString: process.env.DATABASE_URL });
53
+ await db.connect();
54
+ await db.query("BEGIN");
55
+ });
56
+
57
+ afterAll(async () => {
58
+ await db.query("ROLLBACK");
59
+ await db.end();
60
+ });
61
+
62
+ /** Run `run` inside a savepoint; resolve to the violated constraint's name, or null. */
63
+ const violation = async (
64
+ run: () => Promise<unknown>,
65
+ ): Promise<string | null> => {
66
+ await db.query("SAVEPOINT attempt");
67
+ try {
68
+ await run();
69
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
70
+ return null;
71
+ } catch (err) {
72
+ await db.query("ROLLBACK TO SAVEPOINT attempt");
73
+ return (err as { constraint?: string }).constraint ?? String(err);
74
+ }
75
+ };
76
+
77
+ /** Insert one record: the valid row with `cols` over it. */
78
+ const insert = (cols: Record<string, unknown> = {}) => {
79
+ const all = { ...VALID, ...cols };
80
+ const names = Object.keys(all);
81
+ return db.query(
82
+ `INSERT INTO "${TABLE}" (${names.map((n) => `"${n}"`).join(", ")})
83
+ VALUES (${names.map((_, i) => `$${i + 1}`).join(", ")}) RETURNING "id"`,
84
+ names.map((n) => all[n]),
85
+ );
86
+ };
87
+
88
+ describe("AC-1 — exactly the record's columns", () => {
89
+ it("has the eight columns, each NOT NULL", async () => {
90
+ const { rows } = await db.query(
91
+ `SELECT column_name, is_nullable FROM information_schema.columns
92
+ WHERE table_schema = current_schema() AND table_name = $1 ORDER BY column_name`,
93
+ [TABLE],
94
+ );
95
+ expect(rows.map((r) => r.column_name)).toEqual([
96
+ "created_at",
97
+ "id",
98
+ "lookup_key",
99
+ "mode",
100
+ "organization_id",
101
+ "price_id",
102
+ "session_id",
103
+ "workspace_id",
104
+ ]);
105
+ expect(rows.filter((r) => r.is_nullable !== "NO")).toEqual([]);
106
+ });
107
+
108
+ it("carries exactly D2's eight CHECKs", async () => {
109
+ const { rows } = await db.query(
110
+ `SELECT conname FROM pg_constraint
111
+ WHERE conrelid = $1::regclass AND contype = 'c' ORDER BY conname`,
112
+ [TABLE],
113
+ );
114
+ expect(rows.map((r) => r.conname)).toEqual([...CHECKS].sort());
115
+ });
116
+
117
+ it("has one non-primary index: the unique session id key", async () => {
118
+ const { rows } = await db.query(
119
+ `SELECT i.relname AS name, x.indisunique AS unique FROM pg_index x
120
+ JOIN pg_class i ON i.oid = x.indexrelid
121
+ WHERE x.indrelid = $1::regclass AND NOT x.indisprimary`,
122
+ [TABLE],
123
+ );
124
+ expect(rows).toEqual([
125
+ { name: "offerings_checkout_sessions_session_id_key", unique: true },
126
+ ]);
127
+ });
128
+ });
129
+
130
+ describe("AC-2 — Postgres refuses each malformed row by the named constraint", () => {
131
+ it("accepts the valid row and mints a prefixed id", async () => {
132
+ await db.query("SAVEPOINT ok");
133
+ const { rows } = await insert();
134
+ expect(rows[0].id).toMatch(idRegex("offeringsCheckoutSession"));
135
+ await db.query("ROLLBACK TO SAVEPOINT ok");
136
+ });
137
+
138
+ it("accepts a live record of a live session", async () => {
139
+ expect(
140
+ await violation(() =>
141
+ insert({ session_id: "cs_live_a1", mode: "live" }),
142
+ ),
143
+ ).toBeNull();
144
+ });
145
+
146
+ it.each([
147
+ [
148
+ "a charge id",
149
+ { session_id: "ch_test_a1" },
150
+ "offerings_checkout_sessions_session_id_format",
151
+ ],
152
+ [
153
+ "a session id with no body",
154
+ { session_id: "cs_test_" },
155
+ "offerings_checkout_sessions_session_id_format",
156
+ ],
157
+ [
158
+ "a session id with a dash",
159
+ { session_id: "cs_test_a-1" },
160
+ "offerings_checkout_sessions_session_id_format",
161
+ ],
162
+ [
163
+ "a workspace id of another shape",
164
+ { workspace_id: "ws_1" },
165
+ "offerings_checkout_sessions_workspace_id_format",
166
+ ],
167
+ [
168
+ "an organization id of another shape",
169
+ { organization_id: "org_1" },
170
+ "offerings_checkout_sessions_organization_id_format",
171
+ ],
172
+ [
173
+ "an empty Price id",
174
+ { price_id: "" },
175
+ "offerings_checkout_sessions_price_id_format",
176
+ ],
177
+ [
178
+ "a Price id with a space",
179
+ { price_id: "price 1" },
180
+ "offerings_checkout_sessions_price_id_format",
181
+ ],
182
+ [
183
+ "an uppercase lookup key",
184
+ { lookup_key: "Growth_Monthly" },
185
+ "offerings_checkout_sessions_lookup_key_format",
186
+ ],
187
+ [
188
+ "a 41-character plan inside the column width",
189
+ { lookup_key: `${"a".repeat(41)}_once` },
190
+ "offerings_checkout_sessions_lookup_key_format",
191
+ ],
192
+ [
193
+ "a mode of prod",
194
+ { mode: "prod" },
195
+ "offerings_checkout_sessions_mode_domain",
196
+ ],
197
+ [
198
+ "a live record of a test session",
199
+ { mode: "live" },
200
+ "offerings_checkout_sessions_session_mode_agree",
201
+ ],
202
+ [
203
+ "a test record of a live session",
204
+ { session_id: "cs_live_a1" },
205
+ "offerings_checkout_sessions_session_mode_agree",
206
+ ],
207
+ [
208
+ "an id of another shape",
209
+ { id: "ocsess_1" },
210
+ "offerings_checkout_sessions_id_format",
211
+ ],
212
+ ])("refuses %s", async (_label, cols, constraint) => {
213
+ expect(await violation(() => insert(cols))).toBe(constraint);
214
+ });
215
+
216
+ it("refuses a second record of one session", async () => {
217
+ expect(
218
+ await violation(async () => {
219
+ await insert();
220
+ await insert({ price_id: "price_2Def" });
221
+ }),
222
+ ).toBe("offerings_checkout_sessions_session_id_key");
223
+ });
224
+ });
225
+
226
+ describe("AC-3 — the lookup-key CHECK accepts exactly what parseLookupKey accepts", () => {
227
+ const CORPUS = [
228
+ // Contract §3.7's worked examples.
229
+ "growth_monthly",
230
+ "growth_yearly",
231
+ "basic_once",
232
+ "samhenry-coaching_monthly",
233
+ "growth_quarterly",
234
+ "growth_pro_monthly",
235
+ "Growth_Monthly",
236
+ "wendi_retainer",
237
+ // The anchors, the plan's first character, and its length bound.
238
+ "-growth_monthly",
239
+ "growth-_once",
240
+ "growth_monthly ",
241
+ "growth_monthly\n",
242
+ "_monthly",
243
+ `${"a".repeat(40)}_yearly`,
244
+ `${"a".repeat(41)}_monthly`,
245
+ // 46 characters: inside VARCHAR(48), so only the CHECK can refuse it.
246
+ `${"a".repeat(41)}_once`,
247
+ ];
248
+
249
+ it("the corpus holds keys each side accepts and keys each side refuses", () => {
250
+ const accepted = CORPUS.filter((key) => parseLookupKey(key) !== null);
251
+ expect(accepted).toEqual([
252
+ "growth_monthly",
253
+ "growth_yearly",
254
+ "basic_once",
255
+ "samhenry-coaching_monthly",
256
+ "growth-_once",
257
+ `${"a".repeat(40)}_yearly`,
258
+ ]);
259
+ });
260
+
261
+ it.each(CORPUS.map((key) => [JSON.stringify(key), key]))(
262
+ "inserts %s if and only if the parser accepts it",
263
+ async (_label, key) => {
264
+ const refused = await violation(() => insert({ lookup_key: key }));
265
+ expect(refused === null).toBe(parseLookupKey(key) !== null);
266
+ },
267
+ );
268
+ });
269
+ },
270
+ );
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @file offerings-migration.test.ts
3
+ *
4
+ * DB-free proof of the offerings_data_model migration's shape (#8876, spec
5
+ * docs/specs/8876-the-offerings-data-model-typed-spec.md D1, D2, AC-1).
6
+ * Reads the migration's own text and the config schema: it creates exactly
7
+ * the three tables, with every column the models `@map` to, the three
8
+ * `_id_format` CHECKs, the three reference-column format CHECKs and every
9
+ * integrity CHECK D1 names — and touches no other table. The live-database
10
+ * half is `offerings.db.test.ts`.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+
15
+ const MIGRATION = join(
16
+ __dirname,
17
+ "..",
18
+ "migrations",
19
+ "20261020120000_offerings_data_model",
20
+ "migration.sql",
21
+ );
22
+ const SCHEMA = join(__dirname, "..", "config", "schema.prisma");
23
+
24
+ const TABLES: Record<string, { model: string; columns: string[] }> = {
25
+ stripe_connections: {
26
+ model: "StripeConnection",
27
+ columns: [
28
+ "id",
29
+ "organization_id",
30
+ "mode",
31
+ "key_last4",
32
+ "credential_ciphertext",
33
+ "webhook_token",
34
+ "webhook_secret_ciphertext",
35
+ "live_at",
36
+ "created_at",
37
+ "updated_at",
38
+ ],
39
+ },
40
+ organization_offerings: {
41
+ model: "OrganizationOfferings",
42
+ columns: ["id", "organization_id", "enabled_product_ids", "created_at", "updated_at"],
43
+ },
44
+ workspace_offerings: {
45
+ model: "WorkspaceOfferings",
46
+ columns: [
47
+ "id",
48
+ "workspace_id",
49
+ "shown_product_ids",
50
+ "recommended_plan_product_id",
51
+ "recommended_addon_product_id",
52
+ "snapshot",
53
+ "snapshot_at",
54
+ "created_at",
55
+ "updated_at",
56
+ ],
57
+ },
58
+ };
59
+
60
+ /**
61
+ * Columns a later migration adds, after each table's own: #9070's
62
+ * `shown_price_ids` (offerings-shown-price-ids-migration.test.ts).
63
+ */
64
+ const LATER_COLUMNS: Record<string, string[]> = {
65
+ workspace_offerings: ["shown_price_ids"],
66
+ };
67
+
68
+ const CHECKS = [
69
+ "stripe_connections_id_format",
70
+ "stripe_connections_organization_id_format",
71
+ "organization_offerings_id_format",
72
+ "organization_offerings_organization_id_format",
73
+ "workspace_offerings_id_format",
74
+ "workspace_offerings_workspace_id_format",
75
+ "stripe_connections_mode_domain",
76
+ "stripe_connections_live_at_pair",
77
+ "stripe_connections_key_last4_shape",
78
+ "stripe_connections_webhook_token_shape",
79
+ "stripe_connections_credential_envelope",
80
+ "stripe_connections_webhook_secret_envelope",
81
+ "organization_offerings_enabled_product_ids_bound",
82
+ "workspace_offerings_shown_product_ids_bound",
83
+ "workspace_offerings_recommended_plan_shown",
84
+ "workspace_offerings_recommended_addon_shown",
85
+ "workspace_offerings_snapshot_pair",
86
+ ];
87
+
88
+ /** The migration's statements, comments stripped, whitespace collapsed. */
89
+ function statements(): string[] {
90
+ const text = readFileSync(MIGRATION, "utf8")
91
+ .split("\n")
92
+ .filter((line) => !line.trimStart().startsWith("--"))
93
+ .join("\n");
94
+ return text
95
+ .split(";")
96
+ .map((s) => s.replace(/\s+/g, " ").trim())
97
+ .filter((s) => s.length > 0);
98
+ }
99
+
100
+ /** One model's body from the config schema. */
101
+ function modelBody(model: string): string {
102
+ const match = new RegExp(`^model ${model} \\{\\n([\\s\\S]*?)^\\}`, "m").exec(
103
+ readFileSync(SCHEMA, "utf8"),
104
+ );
105
+ if (!match) throw new Error(`model ${model} not found`);
106
+ return match[1]!;
107
+ }
108
+
109
+ /** The column each field of a model maps to: its `@map`, else its own name. */
110
+ function mappedColumns(model: string): string[] {
111
+ return modelBody(model)
112
+ .split("\n")
113
+ .map((line) => line.trim())
114
+ .filter((line) => /^[a-zA-Z]\w*\s/.test(line))
115
+ .map((line) => /@map\("([^"]+)"\)/.exec(line)?.[1] ?? line.split(/\s+/)[0]!);
116
+ }
117
+
118
+ describe("the offerings_data_model migration (#8876 D2, AC-1)", () => {
119
+ it("creates exactly the three tables, each column the model maps to", () => {
120
+ const creates = statements().filter((s) => s.startsWith("CREATE TABLE "));
121
+ expect(creates.map((s) => /^CREATE TABLE "([a-z_]+)"/.exec(s)?.[1])).toEqual(
122
+ Object.keys(TABLES),
123
+ );
124
+ for (const [table, { model, columns }] of Object.entries(TABLES)) {
125
+ const create = creates.find((s) => s.startsWith(`CREATE TABLE "${table}"`))!;
126
+ const created = [...create.matchAll(/(?:\( |, )"([a-z0-9_]+)" [A-Z]/g)].map((m) => m[1]);
127
+ expect(created).toEqual(columns);
128
+ // #9070 D1 — a later migration adds one column to workspace_offerings.
129
+ const later = LATER_COLUMNS[table] ?? [];
130
+ expect(mappedColumns(model).filter((c) => !later.includes(c))).toEqual(columns);
131
+ expect(mappedColumns(model)).toEqual(expect.arrayContaining(later));
132
+ expect(modelBody(model)).toContain(`@@map("${table}")`);
133
+ }
134
+ });
135
+
136
+ it("holds one unique index per key and on the webhook token", () => {
137
+ const indexes = statements()
138
+ .map((s) => /^CREATE UNIQUE INDEX "([a-z0-9_]+)"/.exec(s)?.[1])
139
+ .filter((name): name is string => name !== undefined);
140
+ expect(indexes).toEqual([
141
+ "stripe_connections_organization_id_key",
142
+ "stripe_connections_webhook_token_key",
143
+ "organization_offerings_organization_id_key",
144
+ "workspace_offerings_workspace_id_key",
145
+ ]);
146
+ });
147
+
148
+ it("adds every named CHECK, each once", () => {
149
+ const added = statements()
150
+ .map((s) => /^ALTER TABLE "[a-z_]+" ADD CONSTRAINT "([a-z0-9_]+)" CHECK /.exec(s)?.[1])
151
+ .filter((name): name is string => name !== undefined);
152
+ expect(added).toEqual(CHECKS);
153
+ });
154
+
155
+ it("pins each CHECK's rule", () => {
156
+ const sql = statements().join("\n");
157
+ const rule = (name: string) =>
158
+ new RegExp(`ADD CONSTRAINT "${name}" CHECK (.*)`).exec(sql)?.[1];
159
+ expect(rule("stripe_connections_mode_domain")).toBe(`("mode" IN ('test', 'live'))`);
160
+ expect(rule("stripe_connections_live_at_pair")).toBe(
161
+ `(("mode" = 'live') = ("live_at" IS NOT NULL))`,
162
+ );
163
+ expect(rule("stripe_connections_webhook_token_shape")).toBe(
164
+ `("webhook_token" ~ '^[A-Za-z0-9_-]{43}$')`,
165
+ );
166
+ const envelope = `'^v1\\.[A-Za-z0-9_-]{16}\\.[A-Za-z0-9_-]{22}\\.[A-Za-z0-9_-]+$'`;
167
+ const keyPrefix = `'(rk|sk|pk)_(test|live)_|whsec_'`;
168
+ expect(rule("stripe_connections_credential_envelope")).toBe(
169
+ `("credential_ciphertext" ~ ${envelope} AND "credential_ciphertext" !~ ${keyPrefix})`,
170
+ );
171
+ expect(rule("stripe_connections_webhook_secret_envelope")).toBe(
172
+ `("webhook_secret_ciphertext" IS NULL OR ("webhook_secret_ciphertext" ~ ${envelope} AND "webhook_secret_ciphertext" !~ ${keyPrefix}))`,
173
+ );
174
+ for (const [name, column] of [
175
+ ["organization_offerings_enabled_product_ids_bound", "enabled_product_ids"],
176
+ ["workspace_offerings_shown_product_ids_bound", "shown_product_ids"],
177
+ ]) {
178
+ expect(rule(name!)).toBe(
179
+ `("${column}" IS NOT NULL AND cardinality("${column}") <= 100 AND array_position("${column}", NULL) IS NULL)`,
180
+ );
181
+ }
182
+ expect(rule("workspace_offerings_snapshot_pair")).toBe(
183
+ `(("snapshot" IS NULL) = ("snapshot_at" IS NULL))`,
184
+ );
185
+ });
186
+
187
+ it("touches no other table: no UPDATE, INSERT, DELETE, DROP, or ALTER of another table", () => {
188
+ const own = new Set(Object.keys(TABLES));
189
+ for (const s of statements()) {
190
+ expect(s).not.toMatch(/^(UPDATE|INSERT|DELETE|DROP|TRUNCATE)\b/);
191
+ const altered = /^ALTER TABLE "([a-z_]+)"/.exec(s)?.[1];
192
+ if (altered) expect(own.has(altered)).toBe(true);
193
+ const created = /^CREATE (?:UNIQUE )?INDEX "[a-z0-9_]+" ON "([a-z_]+)"/.exec(s)?.[1];
194
+ if (created) expect(own.has(created)).toBe(true);
195
+ }
196
+ });
197
+ });