create-ailk 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/cli.js +54 -5
  5. package/dist/copy.d.ts +14 -0
  6. package/dist/copy.js +38 -1
  7. package/dist/lib/api-boot-assertion.d.ts +46 -0
  8. package/dist/lib/api-boot-assertion.js +70 -0
  9. package/dist/lib/extract-module-bundle.d.ts +20 -1
  10. package/dist/lib/extract-module-bundle.js +64 -5
  11. package/dist/lib/import-graph.d.ts +29 -0
  12. package/dist/lib/import-graph.js +140 -0
  13. package/dist/lib/module-license-gate.d.ts +6 -1
  14. package/dist/lib/module-license-gate.js +6 -1
  15. package/dist/lib/pro-callout.js +1 -1
  16. package/dist/module-architecture.d.ts +1 -0
  17. package/dist/module-architecture.js +253 -127
  18. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  19. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  20. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  21. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  22. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  23. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  24. package/dist/programmatic.js +14 -10
  25. package/dist/surfaces.js +105 -7
  26. package/dist/template-manifest.d.ts +33 -0
  27. package/dist/template-manifest.js +99 -16
  28. package/dist/template-names.d.ts +1 -1
  29. package/dist/template-names.js +1 -0
  30. package/package.json +3 -2
  31. package/site-templates/marketing-site/DESIGN.md +148 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  34. package/site-templates/marketing-site/ailk-template.json +1 -1
  35. package/site-templates/marketing-site-free/DESIGN.md +148 -0
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  38. package/site-templates/minimalism-site/DESIGN.md +148 -0
  39. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  41. package/site-templates/minimalism-site/ailk-template.json +2 -1
  42. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  43. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  44. package/site-templates/saas-product/DESIGN.md +148 -0
  45. package/site-templates/simple-service/DESIGN.md +148 -0
  46. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  47. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  48. package/site-templates/simple-service/ailk-template.json +10 -0
  49. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  50. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  51. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  52. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  53. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  54. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  55. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  56. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  57. package/site-templates/simple-service/provenance.json +4 -0
  58. package/templates/.env.example +72 -7
  59. package/templates/CLAUDE.md +2 -3
  60. package/templates/DESIGN.md +155 -0
  61. package/templates/README.md +42 -8
  62. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  63. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  64. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  65. package/templates/apps/api/.env.example +13 -0
  66. package/templates/apps/api/CLAUDE.md +26 -2
  67. package/templates/apps/api/jest.config.cjs +3 -0
  68. package/templates/apps/api/package.json +1 -0
  69. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  70. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  71. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  72. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  73. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  74. package/templates/apps/api/src/config/index.ts +62 -0
  75. package/templates/apps/api/src/config/modules.ts +7 -3
  76. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  77. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  78. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  79. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  81. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  82. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  83. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  84. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  85. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  86. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  87. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  88. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  89. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  90. package/templates/apps/api/src/lib/site.ts +208 -9
  91. package/templates/apps/api/src/lib/stripe.ts +66 -0
  92. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  93. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  94. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  95. package/templates/apps/api/src/openapi/spec.ts +543 -2
  96. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  97. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  98. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  99. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  100. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  101. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  102. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  103. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  104. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  105. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  106. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  107. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  108. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  109. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  110. package/templates/apps/api/src/routes/content/index.ts +13 -73
  111. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  112. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  113. package/templates/apps/api/src/routes/content/search.ts +129 -0
  114. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  115. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  116. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  120. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  121. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  122. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  123. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  124. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  125. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  126. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  127. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  128. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  129. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  130. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  131. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  132. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  133. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  134. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  135. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  136. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  137. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  138. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  139. package/templates/apps/api/src/server.ts +439 -49
  140. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  141. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  142. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  143. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  144. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  145. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  146. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  147. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  148. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  149. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  150. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  151. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  152. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  153. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  154. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  155. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  156. package/templates/apps/api/src/services/audit-log.ts +668 -0
  157. package/templates/apps/api/src/services/consent.ts +18 -0
  158. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  159. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  160. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  161. package/templates/apps/api/src/services/entitlements.ts +80 -0
  162. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  163. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  164. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  165. package/templates/apps/api/src/services/listing-config.ts +27 -9
  166. package/templates/apps/api/src/services/project-listings.ts +201 -6
  167. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  168. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  169. package/templates/apps/api/src/services/revalidation.ts +173 -0
  170. package/templates/apps/api/src/services/verified-email.ts +71 -0
  171. package/templates/apps/api/src/vercel-handler.ts +75 -35
  172. package/templates/apps/mcp/.env.example +17 -0
  173. package/templates/apps/mcp/CLAUDE.md +57 -24
  174. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  175. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  176. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  177. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  178. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  179. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  180. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  181. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  182. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  183. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  184. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  185. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  186. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  187. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  188. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  189. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  190. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  191. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  192. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  193. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  194. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  195. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  196. package/templates/apps/mcp/jest.config.cjs +13 -0
  197. package/templates/apps/mcp/package.json +2 -0
  198. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  199. package/templates/apps/mcp/src/config/modules.ts +10 -5
  200. package/templates/apps/mcp/src/http-transport.ts +102 -29
  201. package/templates/apps/mcp/src/index.ts +73 -11
  202. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  203. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  204. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  205. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  206. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  207. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  208. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  209. package/templates/apps/mcp/src/server.ts +59 -35
  210. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  211. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  212. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  213. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  214. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  215. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  216. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  217. package/templates/apps/mcp/src/tools/index.ts +6 -4
  218. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  219. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  220. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  221. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  222. package/templates/apps/web/.env.example +71 -5
  223. package/templates/apps/web/CLAUDE.md +44 -0
  224. package/templates/apps/web/PRODUCT.md +86 -0
  225. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  226. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  227. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  228. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  229. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  230. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  231. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  232. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  233. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  234. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  235. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  236. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  237. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  238. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  239. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  240. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  241. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  242. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  243. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  244. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  245. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  246. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  247. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  248. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  249. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  250. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  251. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  252. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  253. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  254. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  255. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  256. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  257. package/templates/apps/web/auth.ts +407 -8
  258. package/templates/apps/web/jest.config.cjs +8 -4
  259. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  260. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  261. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  262. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  263. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  264. package/templates/apps/web/lib/auth-email.ts +318 -0
  265. package/templates/apps/web/lib/auth-env.ts +85 -0
  266. package/templates/apps/web/lib/auth-routes.ts +55 -0
  267. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  268. package/templates/apps/web/lib/site-brand.tsx +4 -8
  269. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  270. package/templates/apps/web/messages/en.json +187 -0
  271. package/templates/apps/web/middleware.ts +1 -1
  272. package/templates/apps/web/package.json +2 -1
  273. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  274. package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
  275. package/templates/content/_site.mdx +18 -3
  276. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  277. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  278. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  279. package/templates/database/CHANGELOG.md +149 -0
  280. package/templates/database/README.md +10 -3
  281. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  282. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  283. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  284. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  285. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  286. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  287. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  288. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  289. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  290. package/templates/database/__tests__/tenancy.test.ts +141 -0
  291. package/templates/database/config/schema.prisma +79 -2
  292. package/templates/database/content/schema.prisma +87 -11
  293. package/templates/database/inbox/schema.prisma +452 -9
  294. package/templates/database/jest.config.cjs +5 -3
  295. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  296. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  297. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  298. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  299. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  300. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  301. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  302. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  303. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  304. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  305. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  306. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  307. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  308. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  309. package/templates/database/package.json +3 -2
  310. package/templates/database/tenancy.ts +86 -1
  311. package/templates/database/tsconfig.json +1 -0
  312. package/versions.json +34 -34
  313. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  314. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  315. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  316. package/templates/apps/api/src/routes/content/create.ts +0 -115
  317. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  318. package/templates/apps/api/src/routes/content/update.ts +0 -112
  319. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  320. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  321. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  322. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  323. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  324. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  325. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  326. package/templates/apps/web/public/logo-dark.svg +0 -21
  327. package/templates/apps/web/public/logo-light.svg +0 -21
  328. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  329. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,368 @@
1
+ /**
2
+ * @file dunning-policy.test.ts
3
+ * @description Unit tests for the per-tenant dunning grace policy (#6034
4
+ * §§ D4, D5) — the § D4 resolution table row by row, the key form, the
5
+ * rejecting query, and both exported predicates across every recorded
6
+ * state.
7
+ *
8
+ * External dependency boundaries mocked:
9
+ * - @working-theory/database (via moduleNameMapper → prisma-client.js mock; prisma singleton via __mocks__/prisma.ts)
10
+ * - @working-theory/observability logger
11
+ *
12
+ * `isModuleEnabled` reads `process.env.AILK_ENABLED_MODULES` directly — the
13
+ * REAL env boundary, set/restored per `lead-promotion.test.ts`'s convention,
14
+ * rather than a `jest.mock` of the internal `../../config/modules.js` module
15
+ * (CONTRIBUTING.md § Test code quality forbids mocking an internal module).
16
+ */
17
+
18
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
19
+ jest.mock("../../lib/prisma.js");
20
+
21
+ const mockLoggerInfo = jest.fn();
22
+ const mockLoggerWarn = jest.fn();
23
+ const mockLoggerError = jest.fn();
24
+ jest.mock("@working-theory/observability", () => ({
25
+ logger: {
26
+ info: (...args: unknown[]) => mockLoggerInfo(...args),
27
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
28
+ error: (...args: unknown[]) => mockLoggerError(...args),
29
+ debug: jest.fn(),
30
+ },
31
+ }));
32
+
33
+ import {
34
+ mockNoWorkspace,
35
+ mockSentinelWorkspaceExists,
36
+ mockWorkspaceExists,
37
+ SENTINEL_WORKSPACE_ID,
38
+ } from "../../lib/__mocks__/prisma.js";
39
+ import { prisma } from "../../lib/prisma.js";
40
+ import {
41
+ DUNNING_GRACE_DAYS_MAX,
42
+ DUNNING_POLICY_DEFAULTS,
43
+ dunningPolicyFor,
44
+ isSubscriberActionRequired,
45
+ isSubscriberRevoked,
46
+ type DunningEntry,
47
+ } from "../dunning-policy.js";
48
+
49
+ const SITE_ID = "site-abc";
50
+ const EXPECTED_KEY = `commerce.dunning_policy:${SITE_ID}`;
51
+
52
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
53
+
54
+ beforeEach(() => {
55
+ jest.clearAllMocks();
56
+ delete process.env.AILK_ENABLED_MODULES;
57
+ // #6335 — the shared prisma mock no longer resolves a workspace for any id
58
+ // asked of it, so this suite states the one it expects. Every case below
59
+ // reaches `dunningPolicyFor(SITE_ID)`, which resolves the alias before it
60
+ // reads `site_settings`; without this the resolution returns null and the
61
+ // read never happens, so the § D4 table would assert nothing.
62
+ mockWorkspaceExists(prisma, SITE_ID);
63
+ });
64
+
65
+ afterAll(() => {
66
+ if (priorEnabledModules === undefined) {
67
+ delete process.env.AILK_ENABLED_MODULES;
68
+ } else {
69
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
70
+ }
71
+ });
72
+
73
+ // ── § D4's resolution table, row by row ────────────────────────────────────
74
+
75
+ describe("dunningPolicyFor — the resolution table (§ D4)", () => {
76
+ it("no row → the platform default, logged info 'no_policy_row'", async () => {
77
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue(null);
78
+
79
+ const policy = await dunningPolicyFor(SITE_ID);
80
+
81
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
82
+ expect(mockLoggerInfo).toHaveBeenCalledWith(
83
+ expect.objectContaining({ outcome: "no_policy_row", key: EXPECTED_KEY }),
84
+ expect.any(String),
85
+ );
86
+ expect(mockLoggerError).not.toHaveBeenCalled();
87
+ });
88
+
89
+ it.each([
90
+ ["a string", "not-json"],
91
+ ["an array", [1, 2, 3]],
92
+ ["a number", 5],
93
+ ["null", null],
94
+ ])(
95
+ "stored value is %s (not a JSON object) → default, error 'malformed'",
96
+ async (_label, value) => {
97
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({ value });
98
+
99
+ const policy = await dunningPolicyFor(SITE_ID);
100
+
101
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
102
+ expect(mockLoggerError).toHaveBeenCalledWith(
103
+ expect.objectContaining({ outcome: "malformed" }),
104
+ expect.any(String),
105
+ );
106
+ },
107
+ );
108
+
109
+ it("graceDays absent → default, error 'malformed'", async () => {
110
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
111
+ value: {},
112
+ });
113
+
114
+ const policy = await dunningPolicyFor(SITE_ID);
115
+
116
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
117
+ expect(mockLoggerError).toHaveBeenCalledWith(
118
+ expect.objectContaining({ outcome: "malformed" }),
119
+ expect.any(String),
120
+ );
121
+ });
122
+
123
+ it.each([
124
+ ["a string", "14"],
125
+ ["null", null],
126
+ ["a bool", true],
127
+ ["an array", [14]],
128
+ ["an object", { days: 14 }],
129
+ ])(
130
+ "graceDays is %s → default, error 'malformed'",
131
+ async (_label, graceDays) => {
132
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
133
+ value: { graceDays },
134
+ });
135
+
136
+ const policy = await dunningPolicyFor(SITE_ID);
137
+
138
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
139
+ expect(mockLoggerError).toHaveBeenCalledWith(
140
+ expect.objectContaining({ outcome: "malformed" }),
141
+ expect.any(String),
142
+ );
143
+ },
144
+ );
145
+
146
+ it("graceDays is a magnitude JSON can hold but JS cannot (1e999 → Infinity) → default, error 'non_finite'", async () => {
147
+ // `1e999` is the real-world value (a magnitude `JSON.parse` overflows to
148
+ // `Infinity`); `Number.POSITIVE_INFINITY` is used directly here so the
149
+ // literal itself doesn't trip `no-loss-of-precision` — both reach the
150
+ // exact same `Number.isFinite()` guard.
151
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
152
+ value: { graceDays: Number.POSITIVE_INFINITY },
153
+ });
154
+
155
+ const policy = await dunningPolicyFor(SITE_ID);
156
+
157
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
158
+ expect(mockLoggerError).toHaveBeenCalledWith(
159
+ expect.objectContaining({ outcome: "non_finite" }),
160
+ expect.any(String),
161
+ );
162
+ });
163
+
164
+ it("graceDays negative and finite → clamped to 0, error 'clamped_low'", async () => {
165
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
166
+ value: { graceDays: -5 },
167
+ });
168
+
169
+ const policy = await dunningPolicyFor(SITE_ID);
170
+
171
+ expect(policy).toEqual({ graceDays: 0 });
172
+ expect(mockLoggerError).toHaveBeenCalledWith(
173
+ expect.objectContaining({ outcome: "clamped_low", stored: -5 }),
174
+ expect.any(String),
175
+ );
176
+ });
177
+
178
+ it("graceDays finite and > the ceiling → clamped to the ceiling, error 'clamped_high'", async () => {
179
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
180
+ value: { graceDays: 365 },
181
+ });
182
+
183
+ const policy = await dunningPolicyFor(SITE_ID);
184
+
185
+ expect(policy).toEqual({ graceDays: DUNNING_GRACE_DAYS_MAX });
186
+ expect(mockLoggerError).toHaveBeenCalledWith(
187
+ expect.objectContaining({ outcome: "clamped_high", stored: 365 }),
188
+ expect.any(String),
189
+ );
190
+ });
191
+
192
+ it("graceDays non-integer (e.g. 7.5) → floored, warn 'floored'", async () => {
193
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
194
+ value: { graceDays: 7.5 },
195
+ });
196
+
197
+ const policy = await dunningPolicyFor(SITE_ID);
198
+
199
+ expect(policy).toEqual({ graceDays: 7 });
200
+ expect(mockLoggerWarn).toHaveBeenCalledWith(
201
+ expect.objectContaining({ outcome: "floored", stored: 7.5 }),
202
+ expect.any(String),
203
+ );
204
+ expect(mockLoggerError).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("the query itself throws → default, error 'read_failed'", async () => {
208
+ (prisma.siteSetting.findUnique as jest.Mock).mockRejectedValue(
209
+ new Error("connection reset"),
210
+ );
211
+
212
+ const policy = await dunningPolicyFor(SITE_ID);
213
+
214
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
215
+ expect(mockLoggerError).toHaveBeenCalledWith(
216
+ expect.objectContaining({ outcome: "read_failed" }),
217
+ expect.any(String),
218
+ );
219
+ });
220
+
221
+ it("a valid integer in [0, DUNNING_GRACE_DAYS_MAX] → that value, no log", async () => {
222
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
223
+ value: { graceDays: 21 },
224
+ });
225
+
226
+ const policy = await dunningPolicyFor(SITE_ID);
227
+
228
+ expect(policy).toEqual({ graceDays: 21 });
229
+ expect(mockLoggerInfo).not.toHaveBeenCalled();
230
+ expect(mockLoggerWarn).not.toHaveBeenCalled();
231
+ expect(mockLoggerError).not.toHaveBeenCalled();
232
+ });
233
+ });
234
+
235
+ // ── The key form ────────────────────────────────────────────────────────────
236
+
237
+ describe("dunningPolicyFor — the key form", () => {
238
+ it("queries exactly commerce.dunning_policy:<siteId>, never an unsuffixed key", async () => {
239
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
240
+ value: { graceDays: 14 },
241
+ });
242
+
243
+ await dunningPolicyFor(SITE_ID);
244
+
245
+ expect(prisma.siteSetting.findUnique).toHaveBeenCalledTimes(1);
246
+ const call = (prisma.siteSetting.findUnique as jest.Mock).mock.calls[0][0];
247
+ // #6374 D3 — `site_settings` is now unique on (workspace_id, key), so
248
+ // the selector is the COMPOUND `workspaceId_key`, not a bare `key`.
249
+ expect(call).toEqual({
250
+ where: { workspaceId_key: { workspaceId: SITE_ID, key: EXPECTED_KEY } },
251
+ select: { value: true },
252
+ });
253
+ expect(call.where.workspaceId_key.key).not.toBe("commerce.dunning_policy");
254
+ });
255
+
256
+ // ── #6374 AC-7 — the key is built from the UNRESOLVED siteId ─────────────
257
+ it("builds `key` from the UNRESOLVED siteId parameter, even when the alias resolves to something else", async () => {
258
+ // The alias `"default"` resolves to the sentinel workspace id. The KEY
259
+ // must still read `commerce.dunning_policy:default` — the alias is
260
+ // embedded in the key STRING because `site_settings` never had a
261
+ // `site_id` column, and the migration's backfill leaves every key
262
+ // verbatim. Rebuilding the key from the RESOLVED id would match no row
263
+ // and hand every seller the built-in default policy, silently. Only the
264
+ // WORKSPACE half is resolved.
265
+ mockSentinelWorkspaceExists(prisma);
266
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
267
+ value: { graceDays: 14 },
268
+ });
269
+
270
+ await dunningPolicyFor("default");
271
+
272
+ const call = (prisma.siteSetting.findUnique as jest.Mock).mock.calls[0][0];
273
+ expect(call.where.workspaceId_key.key).toBe(
274
+ "commerce.dunning_policy:default",
275
+ );
276
+ expect(call.where.workspaceId_key.workspaceId).toBe(SENTINEL_WORKSPACE_ID);
277
+ });
278
+
279
+ it("an alias that resolves to no workspace returns the platform default and reads nothing", async () => {
280
+ mockNoWorkspace(prisma);
281
+
282
+ const policy = await dunningPolicyFor("no-such-site");
283
+
284
+ expect(policy).toEqual(DUNNING_POLICY_DEFAULTS);
285
+ expect(prisma.siteSetting.findUnique).not.toHaveBeenCalled();
286
+ });
287
+
288
+ it("multi-tenant deployments log the deployment-wide-policy warning once per process", async () => {
289
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
290
+ (prisma.siteSetting.findUnique as jest.Mock).mockResolvedValue({
291
+ value: { graceDays: 14 },
292
+ });
293
+
294
+ await dunningPolicyFor(SITE_ID);
295
+ await dunningPolicyFor(SITE_ID);
296
+
297
+ const deploymentWideCalls = mockLoggerWarn.mock.calls.filter(
298
+ ([fields]) => fields?.outcome === "deployment_wide_policy",
299
+ );
300
+ expect(deploymentWideCalls).toHaveLength(1);
301
+ });
302
+ });
303
+
304
+ // ── The two exported predicates, across every recorded state ───────────────
305
+
306
+ function entry(overrides: Partial<DunningEntry> = {}): DunningEntry {
307
+ return {
308
+ status: "active",
309
+ accessState: "granted",
310
+ dunningArmedAt: null,
311
+ accessRevokesAt: null,
312
+ lastEventAt: 1_700_000_000,
313
+ lastEventId: "evt_base",
314
+ ...overrides,
315
+ };
316
+ }
317
+
318
+ const NOW = Date.parse("2026-09-18T00:00:00.000Z");
319
+
320
+ describe("isSubscriberRevoked / isSubscriberActionRequired — every recorded state", () => {
321
+ it("active — not revoked, no action required", () => {
322
+ const e = entry({ status: "active", accessState: "granted" });
323
+ expect(isSubscriberRevoked(e, NOW)).toBe(false);
324
+ expect(isSubscriberActionRequired(e, NOW)).toBe(false);
325
+ });
326
+
327
+ it("past_due — not revoked, action required", () => {
328
+ const e = entry({
329
+ status: "past_due",
330
+ accessState: "granted",
331
+ dunningArmedAt: "2026-09-10T00:00:00.000Z",
332
+ });
333
+ expect(isSubscriberRevoked(e, NOW)).toBe(false);
334
+ expect(isSubscriberActionRequired(e, NOW)).toBe(true);
335
+ });
336
+
337
+ it("in-grace canceled — not yet revoked, but action IS required (§ 9 SEC-09)", () => {
338
+ const e = entry({
339
+ status: "canceled",
340
+ accessState: "granted",
341
+ dunningArmedAt: "2026-09-10T00:00:00.000Z",
342
+ accessRevokesAt: "2026-09-24T00:00:00.000Z", // in the future relative to NOW
343
+ });
344
+ expect(isSubscriberRevoked(e, NOW)).toBe(false);
345
+ expect(isSubscriberActionRequired(e, NOW)).toBe(true);
346
+ });
347
+
348
+ it("expired canceled — revoked, no further action predicate (already gone)", () => {
349
+ const e = entry({
350
+ status: "canceled",
351
+ accessState: "revoked",
352
+ dunningArmedAt: "2026-08-01T00:00:00.000Z",
353
+ accessRevokesAt: "2026-08-15T00:00:00.000Z", // in the past relative to NOW
354
+ });
355
+ expect(isSubscriberRevoked(e, NOW)).toBe(true);
356
+ expect(isSubscriberActionRequired(e, NOW)).toBe(false);
357
+ });
358
+
359
+ it("accessRevokesAt exactly at `now` is revoked (>=, not >)", () => {
360
+ const revokesAt = "2026-09-18T00:00:00.000Z";
361
+ const e = entry({
362
+ status: "canceled",
363
+ accessState: "granted",
364
+ accessRevokesAt: revokesAt,
365
+ });
366
+ expect(isSubscriberRevoked(e, Date.parse(revokesAt))).toBe(true);
367
+ });
368
+ });
@@ -392,4 +392,164 @@ describe("routeLead", () => {
392
392
  expect(result.error.kind).toBe("invalid_input");
393
393
  expect(prisma.subscriber.create).not.toHaveBeenCalled();
394
394
  });
395
+
396
+ // ── #6203/#6254 — the workspaceId stamp is ALWAYS a copy of siteId ────────
397
+ //
398
+ // routeLead(kind: "submit") has FOUR callers in this repo, and as of
399
+ // #6254 ALL FOUR resolve `siteId` to a real `wksp_<uuidv7>` workspace id
400
+ // before calling in — the three that once passed an unresolved alias
401
+ // (flow-engine.ts, recommender-capture.ts, project-listings.ts's
402
+ // submitProjectListingDraft) now resolve it themselves
403
+ // (`resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`,
404
+ // `apps/api/src/lib/site.ts`). `workspaceId` is NOT NULL as of
405
+ // database/migrations/20260922120000_tenancy_fence, so `persistToDb` can
406
+ // no longer stamp `null` for an unresolved alias the way #6203 once did —
407
+ // it copies `siteId` unconditionally, and a caller that still somehow
408
+ // passed an unresolved alias hits the pre-existing
409
+ // `leads_workspace_id_format` CHECK loudly (via the AC-11 re-raise this
410
+ // file's other describe block proves) instead of landing a
411
+ // silently-unfenced row.
412
+ describe("the workspaceId stamp copies siteId unconditionally (#6203, #6254)", () => {
413
+ it("workspaceId is always the SAME value as siteId, whatever shape siteId carries", async () => {
414
+ await routeLead(leadInput, sourceMeta); // leadInput.siteId === "site-abc"
415
+
416
+ expect(prisma.lead.create).toHaveBeenCalledTimes(1);
417
+ const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
418
+ expect(data.siteId).toBe("site-abc");
419
+ expect(data.workspaceId).toBe("site-abc");
420
+ });
421
+
422
+ it("a workspace-shaped siteId (the routes/leads/index.ts path) stamps workspaceId to the SAME value", async () => {
423
+ const resolvedInput = {
424
+ ...leadInput,
425
+ siteId: "wksp_00000000-0000-7000-8000-000000000001",
426
+ };
427
+
428
+ await routeLead(resolvedInput, sourceMeta);
429
+
430
+ const { data } = (prisma.lead.create as jest.Mock).mock.calls[0]![0];
431
+ expect(data.siteId).toBe("wksp_00000000-0000-7000-8000-000000000001");
432
+ expect(data.workspaceId).toBe("wksp_00000000-0000-7000-8000-000000000001");
433
+ });
434
+ });
435
+
436
+ // #6254 AC-11 (spec D6, SEC-02) — a constraint violation (the new
437
+ // `leads_workspace_site_agree` CHECK, or the pre-existing NOT NULL) must
438
+ // never be swallowed into the file fallback and a silent `ok:true`: it
439
+ // means OUR OWN derivation produced a row Postgres itself refuses, which
440
+ // is a bug to surface, not an outage to degrade through. A GENUINE
441
+ // infrastructure failure (the existing "DB down → file fallback" test
442
+ // above, a plain `Error("connection refused")` that carries none of the
443
+ // signals below) must still take the file fallback, unchanged.
444
+ describe("a constraint violation re-raises as a deliberate error, never a silent success (#6254 AC-11)", () => {
445
+ const listTmpDirFiles = () => fs.readdir(tmpDir);
446
+
447
+ it("a Prisma-mapped class-23 code (e.g. P2011, null constraint) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
448
+ const before = await listTmpDirFiles();
449
+ const constraintErr = Object.assign(
450
+ new Error("Null constraint violation on the field: (`workspace_id`)"),
451
+ { code: "P2011" },
452
+ );
453
+ (prisma.lead.create as jest.Mock).mockRejectedValue(constraintErr);
454
+
455
+ const result = (await routeLead(
456
+ leadInput,
457
+ sourceMeta,
458
+ )) as Extract<RouteLeadResult, { ok: false }>;
459
+
460
+ expect(result.ok).toBe(false);
461
+ expect(result.error.kind).toBe("persist_failed");
462
+ // No file was written — the fallback was never attempted.
463
+ const after = await listTmpDirFiles();
464
+ expect(after).toEqual(before);
465
+ });
466
+
467
+ it("a raw SQLSTATE the message carries (no Prisma-mapped code — the new CHECK constraint's own shape) returns ok:false persist_failed WITHOUT engaging the file fallback", async () => {
468
+ const before = await listTmpDirFiles();
469
+ const checkErr = new Error(
470
+ 'new row for relation "leads" violates check constraint "leads_workspace_site_agree"',
471
+ );
472
+ (prisma.lead.create as jest.Mock).mockRejectedValue(checkErr);
473
+
474
+ const result = (await routeLead(
475
+ leadInput,
476
+ sourceMeta,
477
+ )) as Extract<RouteLeadResult, { ok: false }>;
478
+
479
+ expect(result.ok).toBe(false);
480
+ expect(result.error.kind).toBe("persist_failed");
481
+ const after = await listTmpDirFiles();
482
+ expect(after).toEqual(before);
483
+ });
484
+
485
+ it("a bare SQLSTATE 23502 in the error message also re-raises as persist_failed", async () => {
486
+ const before = await listTmpDirFiles();
487
+ const notNullErr = Object.assign(
488
+ new Error("null value in column \"workspace_id\" violates not-null constraint (23502)"),
489
+ {},
490
+ );
491
+ (prisma.lead.create as jest.Mock).mockRejectedValue(notNullErr);
492
+
493
+ const result = (await routeLead(
494
+ leadInput,
495
+ sourceMeta,
496
+ )) as Extract<RouteLeadResult, { ok: false }>;
497
+
498
+ expect(result.ok).toBe(false);
499
+ expect(result.error.kind).toBe("persist_failed");
500
+ const after = await listTmpDirFiles();
501
+ expect(after).toEqual(before);
502
+ });
503
+
504
+ it("a genuine outage (no class-23 signal) still takes the file fallback — persisted:file, not persist_failed", async () => {
505
+ // Regression guard for the branch above: an ordinary connection error
506
+ // must not be misclassified as a constraint violation.
507
+ (prisma.lead.create as jest.Mock).mockRejectedValue(
508
+ new Error("connection refused"),
509
+ );
510
+
511
+ const result = (await routeLead(
512
+ leadInput,
513
+ sourceMeta,
514
+ )) as Extract<RouteLeadResult, { ok: true }>;
515
+
516
+ expect(result.ok).toBe(true);
517
+ expect(result.persisted).toBe("file");
518
+ });
519
+ });
520
+ });
521
+
522
+ // ─── #6257 — the fence: every fenced read carries workspaceId ───────────────
523
+
524
+ /**
525
+ * Unit C2's contract for this service (#6257 AC-1/AC-2). `routeLead`'s one
526
+ * READ is the duplicate-subscriber lookup on the P2002 arm. `input.siteId` is
527
+ * the resolved workspace id at every caller (the same guarantee the three
528
+ * `create` sites above stamp on), so the lookup is scoped by it rather than
529
+ * resolving a second time.
530
+ */
531
+ describe("#6257 — the duplicate-subscriber lookup carries workspaceId", () => {
532
+ beforeEach(() => {
533
+ jest.clearAllMocks();
534
+ process.env.DATABASE_URL = "postgres://test";
535
+ });
536
+
537
+ it("scopes the existing-subscriber read by the caller's resolved workspace id", async () => {
538
+ (prisma.subscriber.create as jest.Mock).mockRejectedValue(
539
+ Object.assign(new Error("Unique constraint failed"), { code: "P2002" }),
540
+ );
541
+ (prisma.subscriber.findUnique as jest.Mock).mockResolvedValue({ id: "sub_1" });
542
+
543
+ await routeLead(
544
+ { ...subscribeInput, email: "dupe@example.com" },
545
+ sourceMeta,
546
+ );
547
+
548
+ const { where } = (prisma.subscriber.findUnique as jest.Mock).mock.calls[0][0];
549
+ expect(where.workspaceId).toBe(subscribeInput.siteId);
550
+ expect(where.siteId_email).toEqual({
551
+ siteId: subscribeInput.siteId,
552
+ email: "dupe@example.com",
553
+ });
554
+ });
395
555
  });