create-ailk 0.4.0 → 0.6.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,749 @@
1
+ /**
2
+ * POST /v1/webhooks/stripe — the dunning arms (#6034): `invoice.payment_failed`
3
+ * (FR-1) and `customer.subscription.updated` (FR-2/FR-4), the § D6
4
+ * ordering/tie/durable-replay admission rule, and the § D5 grace/revocation
5
+ * computation.
6
+ *
7
+ * Mock shape is `stripe-request-paid.test.ts`'s, deliberately unchanged: the
8
+ * six pre-existing webhook suites are the regression floor for this issue and
9
+ * none of them is edited, so this file mirrors their setup rather than
10
+ * refactoring a shared one.
11
+ */
12
+ import type { FastifyInstance } from "fastify";
13
+ import Fastify from "fastify";
14
+
15
+ const mockTrack = jest.fn().mockResolvedValue(undefined);
16
+
17
+ // Commerce claim (D-64, #5796): every case here is a COMMERCE-CLAIMED
18
+ // deployment, the same posture the sibling webhook suites take.
19
+ jest.mock("@working-theory/license", () => ({
20
+ checkLicense: jest.fn().mockResolvedValue({
21
+ tier: "pro",
22
+ plan: "pro-monthly",
23
+ features: ["commerce"],
24
+ expiresAt: null,
25
+ revoked: false,
26
+ }),
27
+ }));
28
+
29
+ jest.mock("@working-theory/analytics/server", () => ({
30
+ track: (...args: unknown[]) => mockTrack(...args),
31
+ }));
32
+
33
+ // 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.
34
+ jest.mock("../../../lib/prisma.js");
35
+
36
+ // eslint-disable-next-line no-restricted-syntax -- @working-theory/config-modules is not a DB boundary; this repo has no __mocks__/modules.ts fixture, so a factory is the standard shape here.
37
+ jest.mock("../../../config/modules.js", () => ({
38
+ isModuleEnabled: () => false,
39
+ }));
40
+
41
+ const mockVerifyWebhookSignature = jest.fn();
42
+ class MockStripeWebhookSignatureError extends Error {
43
+ constructor(message: string) {
44
+ super(message);
45
+ this.name = "StripeWebhookSignatureError";
46
+ }
47
+ }
48
+ jest.mock("@working-theory/stripe-client", () => {
49
+ // toSubscriptionLifecycleStatus (#6034 § D3) is REAL here, not stubbed —
50
+ // this is the exact "which Stripe statuses imply access" narrowing the
51
+ // webhook reuses, and AC-2 tests its behavior (unpaid → past_due, an
52
+ // unrecognized status → canceled) through this suite.
53
+ const actual = jest.requireActual("@working-theory/stripe-client");
54
+ return {
55
+ verifyWebhookSignature: (...args: unknown[]) =>
56
+ mockVerifyWebhookSignature(...args),
57
+ StripeWebhookSignatureError: MockStripeWebhookSignatureError,
58
+ assertSandboxKey: jest.fn(),
59
+ createCheckoutSession: jest.fn(),
60
+ getStripeClient: jest.fn(),
61
+ STRIPE_API_VERSION: "2024-06-20",
62
+ StripeConfigError: class StripeConfigError extends Error {},
63
+ UnknownProductCodeError: class UnknownProductCodeError extends Error {},
64
+ WEBHOOK_TOLERANCE_SECONDS: 300,
65
+ toSubscriptionLifecycleStatus: actual.toSubscriptionLifecycleStatus,
66
+ };
67
+ });
68
+
69
+ import * as configModule from "../../../config/index.js";
70
+ import { stripeWebhookHandler } from "../stripe.js";
71
+ import { __resetIdempotencyStore } from "../../../lib/webhook-idempotency.js";
72
+ import { prisma } from "../../../lib/prisma.js";
73
+ import { isSubscriberActionRequired } from "../../../services/dunning-policy.js";
74
+
75
+ const CUSTOMER_ID = "cus_test_1234567890";
76
+ const SUBSCRIPTION_ID = "sub_test_1234567890";
77
+ const USER_ID = "user_test_1234567890";
78
+ const DAY_SEC = 24 * 60 * 60;
79
+
80
+ let eventCounter = 0;
81
+ function invoicePaymentFailedEvent(
82
+ object: Record<string, unknown> = {},
83
+ created = 1_700_000_000,
84
+ eventId?: string,
85
+ ) {
86
+ eventCounter += 1;
87
+ return {
88
+ id: eventId ?? `evt_test_${eventCounter}`,
89
+ type: "invoice.payment_failed",
90
+ created,
91
+ data: {
92
+ object: {
93
+ subscription: SUBSCRIPTION_ID,
94
+ customer: CUSTOMER_ID,
95
+ ...object,
96
+ },
97
+ },
98
+ };
99
+ }
100
+
101
+ function subscriptionUpdatedEvent(
102
+ object: Record<string, unknown> = {},
103
+ created = 1_700_000_000,
104
+ eventId?: string,
105
+ ) {
106
+ eventCounter += 1;
107
+ return {
108
+ id: eventId ?? `evt_test_${eventCounter}`,
109
+ type: "customer.subscription.updated",
110
+ created,
111
+ data: {
112
+ object: {
113
+ id: SUBSCRIPTION_ID,
114
+ customer: CUSTOMER_ID,
115
+ status: "past_due",
116
+ ...object,
117
+ },
118
+ },
119
+ };
120
+ }
121
+
122
+ async function buildTestServer(): Promise<FastifyInstance> {
123
+ const server = Fastify({ logger: false });
124
+ await server.register(async (webhooksScope) => {
125
+ webhooksScope.addContentTypeParser(
126
+ "application/json",
127
+ { parseAs: "buffer" },
128
+ (_req, body, done) => {
129
+ done(null, body);
130
+ },
131
+ );
132
+ webhooksScope.post("/v1/webhooks/stripe", stripeWebhookHandler);
133
+ });
134
+ await server.ready();
135
+ return server;
136
+ }
137
+
138
+ const VALID_BODY = Buffer.from(JSON.stringify({ type: "test" }));
139
+ const HEADERS = {
140
+ "content-type": "application/json",
141
+ "stripe-signature": "t=1234,v1=abc",
142
+ };
143
+
144
+ let server: FastifyInstance;
145
+
146
+ beforeEach(async () => {
147
+ jest.clearAllMocks();
148
+ __resetIdempotencyStore();
149
+ jest.replaceProperty(configModule, "config", {
150
+ ...configModule.config,
151
+ STRIPE_WEBHOOK_SECRET: "whsec_test_secret",
152
+ });
153
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: USER_ID });
154
+ (prisma.user.findFirst as jest.Mock).mockResolvedValue(null);
155
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue(null);
156
+ (prisma.userData.create as jest.Mock).mockResolvedValue({});
157
+ (prisma.userData.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
158
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
159
+ value: { graceDays: 14 },
160
+ });
161
+ server = await buildTestServer();
162
+ });
163
+
164
+ afterEach(async () => {
165
+ await server.close();
166
+ jest.restoreAllMocks();
167
+ });
168
+
169
+ async function post() {
170
+ return server.inject({
171
+ method: "POST",
172
+ url: "/v1/webhooks/stripe",
173
+ payload: VALID_BODY,
174
+ headers: HEADERS,
175
+ });
176
+ }
177
+
178
+ // ── AC-1 — invoice.payment_failed arms dunning, never revokes, still 200 ───
179
+
180
+ describe("invoice.payment_failed arms dunning and never revokes (AC-1)", () => {
181
+ it("creates a fresh past_due record for the matched subscriber", async () => {
182
+ mockVerifyWebhookSignature.mockReturnValue(invoicePaymentFailedEvent());
183
+
184
+ const res = await post();
185
+
186
+ expect(res.statusCode).toBe(200);
187
+ expect(JSON.parse(res.body)).toEqual({ received: true });
188
+
189
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
190
+ const call = (prisma.userData.create as jest.Mock).mock.calls[0][0];
191
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
192
+ expect(entry.status).toBe("past_due");
193
+ expect(entry.accessState).toBe("granted");
194
+ expect(entry.dunningArmedAt).not.toBeNull();
195
+ expect(entry.accessRevokesAt).toBeNull();
196
+
197
+ expect(prisma.userData.updateMany).not.toHaveBeenCalled();
198
+ for (const [{ data }] of (prisma.userData.create as jest.Mock).mock.calls) {
199
+ const written = Object.values(data.value.subscriptions) as Array<{
200
+ accessState: string;
201
+ }>;
202
+ expect(written.some((e) => e.accessState === "revoked")).toBe(false);
203
+ }
204
+ });
205
+
206
+ it("issues zero prisma.userData.* and prisma.user.* calls for an invoice with no subscription", async () => {
207
+ mockVerifyWebhookSignature.mockReturnValue(
208
+ invoicePaymentFailedEvent({ subscription: null }),
209
+ );
210
+
211
+ const res = await post();
212
+
213
+ expect(res.statusCode).toBe(200);
214
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
215
+ expect(prisma.userData.create).not.toHaveBeenCalled();
216
+ expect(prisma.userData.updateMany).not.toHaveBeenCalled();
217
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
218
+ });
219
+ });
220
+
221
+ // ── invoice.payment_failed never un-revokes an already-canceled subscriber ──
222
+ // (code review finding, #6034 — a late Smart-Retries webhook for an invoice
223
+ // predating a cancellation can arrive with a genuinely NEWER event.created
224
+ // than the cancellation itself; only customer.subscription.updated may ever
225
+ // clear a recorded `canceled` entry.)
226
+
227
+ describe("invoice.payment_failed never un-revokes an already-canceled subscriber", () => {
228
+ const T = 1_700_000_000;
229
+
230
+ it("preserves a fully-revoked entry even when the invoice event is strictly newer", async () => {
231
+ const canceledEntry = {
232
+ status: "canceled",
233
+ accessState: "revoked",
234
+ dunningArmedAt: new Date((T - 20 * DAY_SEC) * 1000).toISOString(),
235
+ accessRevokesAt: new Date((T - 1 * DAY_SEC) * 1000).toISOString(),
236
+ lastEventAt: T,
237
+ lastEventId: "evt_cancel",
238
+ };
239
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
240
+ value: { v: 1, subscriptions: { [SUBSCRIPTION_ID]: canceledEntry } },
241
+ updatedAt: new Date(),
242
+ });
243
+
244
+ mockVerifyWebhookSignature.mockReturnValue(
245
+ invoicePaymentFailedEvent({}, T + 3600, "evt_late_retry"),
246
+ );
247
+
248
+ const res = await post();
249
+
250
+ expect(res.statusCode).toBe(200);
251
+ // A write may or may not occur (any write is content-identical); what
252
+ // matters is the persisted content is never relaxed.
253
+ for (const [{ data }] of (prisma.userData.updateMany as jest.Mock).mock.calls) {
254
+ expect(data.value.subscriptions[SUBSCRIPTION_ID]).toEqual(canceledEntry);
255
+ }
256
+ expect(prisma.userData.create).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("preserves an in-grace canceled entry (accessState still granted) the same way", async () => {
260
+ const inGraceEntry = {
261
+ status: "canceled",
262
+ accessState: "granted",
263
+ dunningArmedAt: new Date((T - 9 * DAY_SEC) * 1000).toISOString(),
264
+ accessRevokesAt: new Date((T + 5 * DAY_SEC) * 1000).toISOString(),
265
+ lastEventAt: T,
266
+ lastEventId: "evt_cancel_grace",
267
+ };
268
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
269
+ value: { v: 1, subscriptions: { [SUBSCRIPTION_ID]: inGraceEntry } },
270
+ updatedAt: new Date(),
271
+ });
272
+
273
+ mockVerifyWebhookSignature.mockReturnValue(
274
+ invoicePaymentFailedEvent({}, T + 3600, "evt_late_retry2"),
275
+ );
276
+
277
+ const res = await post();
278
+
279
+ expect(res.statusCode).toBe(200);
280
+ for (const [{ data }] of (prisma.userData.updateMany as jest.Mock).mock.calls) {
281
+ expect(data.value.subscriptions[SUBSCRIPTION_ID]).toEqual(inGraceEntry);
282
+ }
283
+ expect(prisma.userData.create).not.toHaveBeenCalled();
284
+ });
285
+ });
286
+
287
+ // ── AC-2 — the three statuses, and grace before revocation ─────────────────
288
+
289
+ describe("customer.subscription.updated — three statuses, grace before revocation (AC-2)", () => {
290
+ it("(a) past_due narrows to a granted past_due record", async () => {
291
+ mockVerifyWebhookSignature.mockReturnValue(
292
+ subscriptionUpdatedEvent({ status: "past_due" }),
293
+ );
294
+
295
+ const res = await post();
296
+
297
+ expect(res.statusCode).toBe(200);
298
+ const call = (prisma.userData.create as jest.Mock).mock.calls[0][0];
299
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
300
+ expect(entry.status).toBe("past_due");
301
+ expect(entry.accessState).toBe("granted");
302
+ });
303
+
304
+ it("(b) unpaid narrows to the SAME past_due record (§ 1.10)", async () => {
305
+ mockVerifyWebhookSignature.mockReturnValue(
306
+ subscriptionUpdatedEvent({ status: "unpaid" }),
307
+ );
308
+
309
+ const res = await post();
310
+
311
+ expect(res.statusCode).toBe(200);
312
+ const call = (prisma.userData.create as jest.Mock).mock.calls[0][0];
313
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
314
+ expect(entry.status).toBe("past_due");
315
+ expect(entry.accessState).toBe("granted");
316
+ });
317
+
318
+ it("(c) canceled inside the grace window retains access and schedules accessRevokesAt", async () => {
319
+ const armedAtMs = Date.now() - 13 * DAY_SEC * 1000;
320
+ const armedAt = new Date(armedAtMs).toISOString();
321
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
322
+ value: {
323
+ v: 1,
324
+ subscriptions: {
325
+ [SUBSCRIPTION_ID]: {
326
+ status: "past_due",
327
+ accessState: "granted",
328
+ dunningArmedAt: armedAt,
329
+ accessRevokesAt: null,
330
+ lastEventAt: 1_600_000_000,
331
+ lastEventId: "evt_prev",
332
+ },
333
+ },
334
+ },
335
+ updatedAt: new Date(),
336
+ });
337
+
338
+ mockVerifyWebhookSignature.mockReturnValue(
339
+ subscriptionUpdatedEvent({ status: "canceled" }, 1_700_000_000),
340
+ );
341
+
342
+ const res = await post();
343
+
344
+ expect(res.statusCode).toBe(200);
345
+ const call = (prisma.userData.updateMany as jest.Mock).mock.calls[0][0];
346
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
347
+ expect(entry.status).toBe("canceled");
348
+ expect(entry.accessState).toBe("granted");
349
+ expect(entry.dunningArmedAt).toBe(armedAt);
350
+ expect(entry.accessRevokesAt).toBe(
351
+ new Date(armedAtMs + 14 * DAY_SEC * 1000).toISOString(),
352
+ );
353
+ });
354
+
355
+ it("(d) canceled past the grace window revokes access", async () => {
356
+ const armedAtMs = Date.now() - 15 * DAY_SEC * 1000;
357
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
358
+ value: {
359
+ v: 1,
360
+ subscriptions: {
361
+ [SUBSCRIPTION_ID]: {
362
+ status: "past_due",
363
+ accessState: "granted",
364
+ dunningArmedAt: new Date(armedAtMs).toISOString(),
365
+ accessRevokesAt: null,
366
+ lastEventAt: 1_600_000_000,
367
+ lastEventId: "evt_prev",
368
+ },
369
+ },
370
+ },
371
+ updatedAt: new Date(),
372
+ });
373
+
374
+ mockVerifyWebhookSignature.mockReturnValue(
375
+ subscriptionUpdatedEvent({ status: "canceled" }, 1_700_000_000),
376
+ );
377
+
378
+ const res = await post();
379
+
380
+ expect(res.statusCode).toBe(200);
381
+ const call = (prisma.userData.updateMany as jest.Mock).mock.calls[0][0];
382
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
383
+ expect(entry.status).toBe("canceled");
384
+ expect(entry.accessState).toBe("revoked");
385
+ });
386
+ });
387
+
388
+ // ── AC-4 — recovery is clean ────────────────────────────────────────────────
389
+
390
+ describe("recovery is clean (AC-4)", () => {
391
+ it("a strictly-newer active event returns the entry to a clean state", async () => {
392
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
393
+ value: {
394
+ v: 1,
395
+ subscriptions: {
396
+ [SUBSCRIPTION_ID]: {
397
+ status: "past_due",
398
+ accessState: "granted",
399
+ dunningArmedAt: new Date(
400
+ Date.now() - 3 * DAY_SEC * 1000,
401
+ ).toISOString(),
402
+ accessRevokesAt: null,
403
+ lastEventAt: 1_600_000_000,
404
+ lastEventId: "evt_prev",
405
+ },
406
+ },
407
+ },
408
+ updatedAt: new Date(),
409
+ });
410
+
411
+ mockVerifyWebhookSignature.mockReturnValue(
412
+ subscriptionUpdatedEvent(
413
+ { status: "active" },
414
+ 1_700_000_000,
415
+ "evt_recover",
416
+ ),
417
+ );
418
+
419
+ const res = await post();
420
+
421
+ expect(res.statusCode).toBe(200);
422
+ const call = (prisma.userData.updateMany as jest.Mock).mock.calls[0][0];
423
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
424
+ expect(entry).toEqual({
425
+ status: "active",
426
+ accessState: "granted",
427
+ dunningArmedAt: null,
428
+ accessRevokesAt: null,
429
+ lastEventAt: 1_700_000_000,
430
+ lastEventId: "evt_recover",
431
+ });
432
+ // #6038's read shows nothing for this shape — pinned via the EXPORTED
433
+ // predicate, not the superseded inline expression (finalize-spec
434
+ // required-change on § 5.1 AC-4).
435
+ expect(isSubscriberActionRequired(entry)).toBe(false);
436
+ });
437
+ });
438
+
439
+ // ── AC-5 — both arms honor the replay short-circuit ────────────────────────
440
+
441
+ describe("both arms honor the replay short-circuit (AC-5)", () => {
442
+ it("invoice.payment_failed — a replayed event id makes zero additional Prisma calls", async () => {
443
+ const event = invoicePaymentFailedEvent(
444
+ {},
445
+ 1_700_000_000,
446
+ "evt_replay_invoice",
447
+ );
448
+ mockVerifyWebhookSignature.mockReturnValue(event);
449
+ await post();
450
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
451
+
452
+ jest.clearAllMocks();
453
+ mockVerifyWebhookSignature.mockReturnValue(event);
454
+ const res = await post();
455
+
456
+ expect(res.statusCode).toBe(200);
457
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
458
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
459
+ expect(prisma.userData.create).not.toHaveBeenCalled();
460
+ });
461
+
462
+ it("customer.subscription.updated — a replayed event id makes zero additional Prisma calls", async () => {
463
+ const event = subscriptionUpdatedEvent(
464
+ { status: "past_due" },
465
+ 1_700_000_000,
466
+ "evt_replay_sub",
467
+ );
468
+ mockVerifyWebhookSignature.mockReturnValue(event);
469
+ await post();
470
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
471
+
472
+ jest.clearAllMocks();
473
+ mockVerifyWebhookSignature.mockReturnValue(event);
474
+ const res = await post();
475
+
476
+ expect(res.statusCode).toBe(200);
477
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
478
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
479
+ });
480
+ });
481
+
482
+ // ── AC-6 — ordering, ties, and the durable replay twin (§ D6) ──────────────
483
+
484
+ describe("ordering, ties, and the durable replay twin (AC-6, § D6)", () => {
485
+ const T = 1_700_000_000;
486
+
487
+ it("(a) a strictly older event is rejected as stale — the entry is unchanged", async () => {
488
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
489
+ value: {
490
+ v: 1,
491
+ subscriptions: {
492
+ [SUBSCRIPTION_ID]: {
493
+ status: "canceled",
494
+ accessState: "revoked",
495
+ dunningArmedAt: new Date((T - 20 * DAY_SEC) * 1000).toISOString(),
496
+ accessRevokesAt: new Date((T - 1 * DAY_SEC) * 1000).toISOString(),
497
+ lastEventAt: T,
498
+ lastEventId: "evt_current",
499
+ },
500
+ },
501
+ },
502
+ updatedAt: new Date(),
503
+ });
504
+
505
+ mockVerifyWebhookSignature.mockReturnValue(
506
+ subscriptionUpdatedEvent({ status: "active" }, T - 3600, "evt_stale"),
507
+ );
508
+
509
+ const res = await post();
510
+
511
+ expect(res.statusCode).toBe(200);
512
+ expect(prisma.userData.updateMany).not.toHaveBeenCalled();
513
+ expect(prisma.userData.create).not.toHaveBeenCalled();
514
+ });
515
+
516
+ it("(b) an equal-timestamp de-escalating write is rejected as a stale tie — SEC-03", async () => {
517
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
518
+ value: {
519
+ v: 1,
520
+ subscriptions: {
521
+ [SUBSCRIPTION_ID]: {
522
+ status: "canceled",
523
+ accessState: "granted", // in-grace cancellation
524
+ dunningArmedAt: new Date((T - 9 * DAY_SEC) * 1000).toISOString(),
525
+ accessRevokesAt: new Date((T + 5 * DAY_SEC) * 1000).toISOString(),
526
+ lastEventAt: T,
527
+ lastEventId: "evt_current",
528
+ },
529
+ },
530
+ },
531
+ updatedAt: new Date(),
532
+ });
533
+
534
+ mockVerifyWebhookSignature.mockReturnValue(
535
+ subscriptionUpdatedEvent({ status: "active" }, T, "evt_reordered_tie"),
536
+ );
537
+
538
+ const res = await post();
539
+
540
+ expect(res.statusCode).toBe(200);
541
+ expect(prisma.userData.updateMany).not.toHaveBeenCalled();
542
+ expect(prisma.userData.create).not.toHaveBeenCalled();
543
+ });
544
+
545
+ it("(c) a durable replay (same event id, hasSeen forgotten) is a no-op via lastEventId", async () => {
546
+ (prisma.userData.findUnique as jest.Mock)
547
+ .mockResolvedValueOnce(null)
548
+ .mockResolvedValue({
549
+ value: {
550
+ v: 1,
551
+ subscriptions: {
552
+ [SUBSCRIPTION_ID]: {
553
+ status: "past_due",
554
+ accessState: "granted",
555
+ dunningArmedAt: new Date().toISOString(),
556
+ accessRevokesAt: null,
557
+ lastEventAt: T,
558
+ lastEventId: "evt_dup",
559
+ },
560
+ },
561
+ },
562
+ updatedAt: new Date(),
563
+ });
564
+
565
+ const event = subscriptionUpdatedEvent(
566
+ { status: "past_due" },
567
+ T,
568
+ "evt_dup",
569
+ );
570
+ mockVerifyWebhookSignature.mockReturnValue(event);
571
+ const first = await post();
572
+ expect(first.statusCode).toBe(200);
573
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
574
+
575
+ // The process-restart case — hasSeen has forgotten the id, but the
576
+ // DURABLE record (lastEventId) has not.
577
+ __resetIdempotencyStore();
578
+ mockVerifyWebhookSignature.mockReturnValue(event);
579
+ const second = await post();
580
+
581
+ expect(second.statusCode).toBe(200);
582
+ expect(prisma.userData.create).toHaveBeenCalledTimes(1);
583
+ expect(prisma.userData.updateMany).not.toHaveBeenCalled();
584
+ });
585
+
586
+ it("(d) a genuinely newer event DOES restore granted access", async () => {
587
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
588
+ value: {
589
+ v: 1,
590
+ subscriptions: {
591
+ [SUBSCRIPTION_ID]: {
592
+ status: "canceled",
593
+ accessState: "revoked",
594
+ dunningArmedAt: new Date((T - 20 * DAY_SEC) * 1000).toISOString(),
595
+ accessRevokesAt: new Date((T - 1 * DAY_SEC) * 1000).toISOString(),
596
+ lastEventAt: T,
597
+ lastEventId: "evt_current",
598
+ },
599
+ },
600
+ },
601
+ updatedAt: new Date(),
602
+ });
603
+
604
+ mockVerifyWebhookSignature.mockReturnValue(
605
+ subscriptionUpdatedEvent({ status: "active" }, T + 3600, "evt_newer"),
606
+ );
607
+
608
+ const res = await post();
609
+
610
+ expect(res.statusCode).toBe(200);
611
+ expect(prisma.userData.updateMany).toHaveBeenCalledTimes(1);
612
+ const call = (prisma.userData.updateMany as jest.Mock).mock.calls[0][0];
613
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
614
+ expect(entry).toEqual({
615
+ status: "active",
616
+ accessState: "granted",
617
+ dunningArmedAt: null,
618
+ accessRevokesAt: null,
619
+ lastEventAt: T + 3600,
620
+ lastEventId: "evt_newer",
621
+ });
622
+ });
623
+
624
+ describe("(e) the CAS conflict path", () => {
625
+ it("retries exactly three times, then answers 200 with no write on exhaustion", async () => {
626
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
627
+ value: { v: 1, subscriptions: {} },
628
+ updatedAt: new Date(),
629
+ });
630
+ (prisma.userData.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
631
+
632
+ mockVerifyWebhookSignature.mockReturnValue(
633
+ subscriptionUpdatedEvent({ status: "past_due" }),
634
+ );
635
+
636
+ const res = await post();
637
+
638
+ expect(res.statusCode).toBe(200);
639
+ expect(prisma.userData.updateMany).toHaveBeenCalledTimes(3);
640
+ for (const [{ where }] of (prisma.userData.updateMany as jest.Mock).mock
641
+ .calls) {
642
+ expect(where).toHaveProperty("updatedAt");
643
+ expect(where.value).toEqual({ equals: { v: 1, subscriptions: {} } });
644
+ }
645
+ });
646
+
647
+ it("a retry that succeeds on the second attempt writes exactly once more", async () => {
648
+ (prisma.userData.findUnique as jest.Mock).mockResolvedValue({
649
+ value: { v: 1, subscriptions: {} },
650
+ updatedAt: new Date(),
651
+ });
652
+ (prisma.userData.updateMany as jest.Mock)
653
+ .mockResolvedValueOnce({ count: 0 })
654
+ .mockResolvedValueOnce({ count: 1 });
655
+
656
+ mockVerifyWebhookSignature.mockReturnValue(
657
+ subscriptionUpdatedEvent({ status: "past_due" }),
658
+ );
659
+
660
+ const res = await post();
661
+
662
+ expect(res.statusCode).toBe(200);
663
+ expect(prisma.userData.updateMany).toHaveBeenCalledTimes(2);
664
+ });
665
+ });
666
+ });
667
+
668
+ // ── § D9 — every identifier is guarded before use ──────────────────────────
669
+
670
+ describe("D9 — every identifier is guarded before use", () => {
671
+ it("customer.subscription.updated with no resolvable customer never queries", async () => {
672
+ mockVerifyWebhookSignature.mockReturnValue(
673
+ subscriptionUpdatedEvent({ customer: null }),
674
+ );
675
+
676
+ const res = await post();
677
+
678
+ expect(res.statusCode).toBe(200);
679
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
680
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
681
+ });
682
+
683
+ it("customer.subscription.updated with no resolvable subscription id never queries userData", async () => {
684
+ mockVerifyWebhookSignature.mockReturnValue(
685
+ subscriptionUpdatedEvent({ id: undefined }),
686
+ );
687
+
688
+ const res = await post();
689
+
690
+ expect(res.statusCode).toBe(200);
691
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
692
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
693
+ });
694
+
695
+ it("invoice.payment_failed with no resolvable customer never queries", async () => {
696
+ mockVerifyWebhookSignature.mockReturnValue(
697
+ invoicePaymentFailedEvent({ customer: null }),
698
+ );
699
+
700
+ const res = await post();
701
+
702
+ expect(res.statusCode).toBe(200);
703
+ expect(prisma.user.findUnique).not.toHaveBeenCalled();
704
+ });
705
+
706
+ it("an unrecognized subscription.status narrows to canceled — never an implied grant", async () => {
707
+ mockVerifyWebhookSignature.mockReturnValue(
708
+ subscriptionUpdatedEvent({ status: "some_future_status" }),
709
+ );
710
+
711
+ const res = await post();
712
+
713
+ expect(res.statusCode).toBe(200);
714
+ const call = (prisma.userData.create as jest.Mock).mock.calls[0][0];
715
+ const entry = call.data.value.subscriptions[SUBSCRIPTION_ID];
716
+ expect(entry.status).toBe("canceled");
717
+ });
718
+
719
+ it("no matching User row is a warn-and-continue — 200, no write, no User created", async () => {
720
+ (prisma.user.findUnique as jest.Mock).mockResolvedValue(null);
721
+ mockVerifyWebhookSignature.mockReturnValue(invoicePaymentFailedEvent());
722
+
723
+ const res = await post();
724
+
725
+ expect(res.statusCode).toBe(200);
726
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
727
+ expect(prisma.user.create).not.toHaveBeenCalled();
728
+ });
729
+ });
730
+
731
+ // ── The signature still settles first ──────────────────────────────────────
732
+
733
+ describe("the signature is still settled before anything else", () => {
734
+ it("answers 400 on an unsigned body and touches no dunning record", async () => {
735
+ mockVerifyWebhookSignature.mockImplementation(() => {
736
+ throw new MockStripeWebhookSignatureError("bad signature");
737
+ });
738
+
739
+ const res = await server.inject({
740
+ method: "POST",
741
+ url: "/v1/webhooks/stripe",
742
+ payload: VALID_BODY,
743
+ headers: { "content-type": "application/json" },
744
+ });
745
+
746
+ expect(res.statusCode).toBe(400);
747
+ expect(prisma.userData.findUnique).not.toHaveBeenCalled();
748
+ });
749
+ });