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,711 @@
1
+ /**
2
+ * @file fence-fan-out.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the #6374 fence fan-out constraints (AC-4, AC-5).
5
+ * Named after — and modelled line-for-line on — its sibling
6
+ * `composite-fence.db.test.ts`, which proves the same shape for the #6254
7
+ * keystone's four already-keyed models. Requires DATABASE_URL pointing at a
8
+ * Postgres with the migrations applied (`prisma migrate deploy`); the whole
9
+ * suite SKIPS when DATABASE_URL is unset, matching every other `.db.test.ts`
10
+ * here and keeping the workspace Jest run DB-free.
11
+ *
12
+ * Raw SQL through `pg`, for the same reason those files give: the generated
13
+ * Prisma client is ESM-only and cannot load under this package's CJS ts-jest
14
+ * transform.
15
+ *
16
+ * What it proves (spec § 6 AC-4 / AC-5):
17
+ *
18
+ * AC-4, for EACH of the eleven `site_id` tables the amendment fences:
19
+ * 1. an insert with `workspace_id` NULL is rejected — 23502;
20
+ * 2. an insert whose `workspace_id` differs from its `site_id` is
21
+ * rejected BY `<table>_workspace_site_agree` — 23514, and the
22
+ * constraint is named in the assertion, so a row rejected by some
23
+ * OTHER constraint cannot pass this test;
24
+ * 3. an insert where the two AGREE succeeds — without this, assertion 2
25
+ * would also pass against a table that rejects everything;
26
+ * 4. no row carries `site_id = 'default'` after the migration — the
27
+ * normalization (D1 step 2) actually ran.
28
+ *
29
+ * AC-5, the four shapes that are NOT the eleven-table pattern:
30
+ * 5. `site_settings` is keyed by (workspace_id, key), not by key alone
31
+ * (D3): two workspaces may each hold `feature_flags`; a SECOND one in
32
+ * ONE workspace is rejected — 23505.
33
+ * 6. `listing_csv_sends` rejects a NULL `workspace_id` (D2) — it has no
34
+ * `site_id`, so it takes NOT NULL + the format CHECK and no equality
35
+ * CHECK.
36
+ * 7. `deliverable_grants` (D4) stays NULL-TOLERANT — NULL is the serve
37
+ * path's fail-closed sentinel — while a STAMPED grant may never
38
+ * disagree with its site.
39
+ * 8. Deleting a `workspaces` row leaves that workspace's `consent_grants`
40
+ * rows in place: the fence is a CHECK, never a foreign key (NFR1,
41
+ * ADR-0009 invariant #1). A cascade here would be silent data loss.
42
+ */
43
+ import { randomUUID } from "node:crypto";
44
+ import { readFileSync } from "node:fs";
45
+ import { join } from "node:path";
46
+
47
+ import { Client } from "pg";
48
+
49
+ /**
50
+ * The SHIPPED migration text. AC-14's two cases execute the guard block out of
51
+ * this file rather than a re-typed copy, so an edit to the guard that changed
52
+ * its classification would fail here — the same technique
53
+ * `composite-fence.db.test.ts` uses to pin the sentinel literal.
54
+ */
55
+ const MIGRATION_SQL = readFileSync(
56
+ join(__dirname, "..", "migrations", "20260922120000_tenancy_fence", "migration.sql"),
57
+ "utf-8",
58
+ );
59
+
60
+ /**
61
+ * The WHOLE `site_settings` attribution sequence (AC-14), extracted from the
62
+ * shipped migration: the abort-not-guess guard, then the sentinel UPDATE,
63
+ * then the post-backfill assertion — the migration's first, second and third
64
+ * statements after the marker, in order.
65
+ *
66
+ * Slicing through the SECOND `END $$;` rather than the first is what makes the
67
+ * non-abort cases meaningful: the guard alone only proves a row was not
68
+ * REJECTED, while the guard-plus-UPDATE proves it was actually ATTRIBUTED, and
69
+ * to which workspace. A slice that stopped at the first block would pass
70
+ * identically whether the UPDATE backfilled the sentinel or did nothing at all.
71
+ */
72
+ function siteSettingsGuardSql(): string {
73
+ const marker = "-- ── ABORT, NEVER GUESS";
74
+ const start = MIGRATION_SQL.indexOf(marker);
75
+ if (start < 0) throw new Error("AC-14 guard block not found in migration.sql");
76
+ const doStart = MIGRATION_SQL.indexOf("DO $$", start);
77
+ if (doStart < 0) throw new Error("AC-14 guard DO block malformed");
78
+ const END = "END $$;";
79
+ // The guard's own terminator, then the post-backfill assertion's.
80
+ const firstEnd = MIGRATION_SQL.indexOf(END, doStart);
81
+ const secondEnd = firstEnd < 0 ? -1 : MIGRATION_SQL.indexOf(END, firstEnd + END.length);
82
+ if (firstEnd < 0 || secondEnd < 0) {
83
+ throw new Error("AC-14 slice malformed — expected two DO blocks after the marker");
84
+ }
85
+ const sql = MIGRATION_SQL.slice(doStart, secondEnd + END.length);
86
+ // Fail loudly if the migration is reshaped so the slice no longer spans the
87
+ // UPDATE — otherwise the non-abort cases would silently stop proving it.
88
+ if (!sql.includes('UPDATE "site_settings"')) {
89
+ throw new Error("AC-14 slice no longer spans the sentinel UPDATE");
90
+ }
91
+ return sql;
92
+ }
93
+
94
+ /** Postgres's own error shape carries `code` as a 5-character SQLSTATE. */
95
+ type PgError = { code?: string; constraint?: string };
96
+
97
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
98
+
99
+ /** A run-unique suffix so repeated local runs never collide. */
100
+ const RUN = randomUUID().slice(0, 8);
101
+
102
+ /** The seeded sentinel workspace — `database/tenancy.ts`'s DEFAULT_WORKSPACE_ID. */
103
+ const SENTINEL_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
104
+
105
+ /**
106
+ * The eleven `site_id` tables of spec § 1.1, each with the columns its own
107
+ * NOT NULLs require BESIDES `site_id`/`workspace_id`. `cols` and `vals` are
108
+ * positional twins: `vals` starts at `$3` because `$1`/`$2` are always
109
+ * `site_id`/`workspace_id`, which every case below supplies itself.
110
+ */
111
+ interface FenceTable {
112
+ /** The Postgres table name — also the constraint-name stem. */
113
+ table: string;
114
+ /** Extra NOT NULL columns, already quoted-safe (plain lower_snake). */
115
+ cols: string[];
116
+ /** One value per `cols` entry, in order. */
117
+ vals: () => unknown[];
118
+ }
119
+
120
+ const TABLES: FenceTable[] = [
121
+ {
122
+ table: "subscribers",
123
+ cols: ["email", "source"],
124
+ vals: () => [`sub-${RUN}-${randomUUID()}@example.com`, `6374-${RUN}`],
125
+ },
126
+ {
127
+ table: "callbacks",
128
+ cols: ["name", "phone", "payload"],
129
+ vals: () => [`6374 ${RUN}`, "+10000000000", "{}"],
130
+ },
131
+ {
132
+ table: "consent_grants",
133
+ cols: [
134
+ "subject_type",
135
+ "subject_id",
136
+ "purpose_id",
137
+ "granted",
138
+ "locale",
139
+ "statement",
140
+ "statement_hash",
141
+ ],
142
+ vals: () => [
143
+ "subscriber",
144
+ `subj-${RUN}-${randomUUID()}`,
145
+ `purpose-${RUN}`,
146
+ true,
147
+ "en",
148
+ "I agree",
149
+ `hash-${RUN}`,
150
+ ],
151
+ },
152
+ {
153
+ table: "appointments",
154
+ cols: [
155
+ "provider_booking_uid",
156
+ "start_time",
157
+ "end_time",
158
+ "attendee_name",
159
+ "attendee_email",
160
+ "status",
161
+ "lifecycle_event_at",
162
+ "updated_at",
163
+ ],
164
+ vals: () => [
165
+ `uid-${RUN}-${randomUUID().slice(0, 8)}`,
166
+ new Date("2026-10-01T10:00:00Z"),
167
+ new Date("2026-10-01T11:00:00Z"),
168
+ "Attendee",
169
+ `appt-${RUN}@example.com`,
170
+ "confirmed",
171
+ new Date("2026-09-22T00:00:00Z"),
172
+ new Date("2026-09-22T00:00:00Z"),
173
+ ],
174
+ },
175
+ {
176
+ table: "deliverable_assets",
177
+ cols: ["key", "filename", "content_type", "bytes", "size", "updated_at"],
178
+ vals: () => [
179
+ `key-${RUN}-${randomUUID().slice(0, 8)}`,
180
+ "file.pdf",
181
+ "application/pdf",
182
+ Buffer.from("bytes"),
183
+ 5,
184
+ new Date("2026-09-22T00:00:00Z"),
185
+ ],
186
+ },
187
+ {
188
+ table: "flow_checkouts",
189
+ cols: [
190
+ "flow_slug",
191
+ "experiment_key",
192
+ "session_id",
193
+ "lead_id",
194
+ "offer_key",
195
+ "seats",
196
+ "period",
197
+ "credit_stop",
198
+ "amount_minor",
199
+ "currency",
200
+ "quote",
201
+ "permalink",
202
+ "status",
203
+ "updated_at",
204
+ ],
205
+ vals: () => [
206
+ `flow-${RUN}`,
207
+ `exp-${RUN}`,
208
+ `sess-${RUN}-${randomUUID().slice(0, 8)}`,
209
+ `lead-${RUN}-${randomUUID().slice(0, 8)}`,
210
+ "offer",
211
+ 1,
212
+ "monthly",
213
+ "{}",
214
+ 1000,
215
+ "usd",
216
+ "{}",
217
+ "permalink",
218
+ "created",
219
+ new Date("2026-09-22T00:00:00Z"),
220
+ ],
221
+ },
222
+ {
223
+ table: "waitlist_experiments",
224
+ cols: ["key", "mode", "status", "config", "updated_at"],
225
+ vals: () => [
226
+ `exp-${RUN}-${randomUUID().slice(0, 8)}`,
227
+ "lead-capture",
228
+ "live",
229
+ "{}",
230
+ new Date("2026-09-22T00:00:00Z"),
231
+ ],
232
+ },
233
+ {
234
+ table: "waitlist_scores",
235
+ cols: [
236
+ "lead_id",
237
+ "experiment_key",
238
+ "waitlist_key",
239
+ "tier",
240
+ "score",
241
+ "category_scores",
242
+ "rule_trace",
243
+ "updated_at",
244
+ ],
245
+ vals: () => [
246
+ `lead-${RUN}-${randomUUID().slice(0, 8)}`,
247
+ `exp-${RUN}`,
248
+ `wl-${RUN}`,
249
+ "a",
250
+ 10,
251
+ "{}",
252
+ "{}",
253
+ new Date("2026-09-22T00:00:00Z"),
254
+ ],
255
+ },
256
+ {
257
+ table: "waitlist_followup_sends",
258
+ cols: ["lead_id", "follow_up_key", "sent_at", "status"],
259
+ vals: () => [
260
+ `lead-${RUN}-${randomUUID().slice(0, 8)}`,
261
+ `fu-${RUN}`,
262
+ new Date("2026-09-22T00:00:00Z"),
263
+ "sent",
264
+ ],
265
+ },
266
+ {
267
+ table: "playbook_instances",
268
+ cols: [
269
+ "lead_id",
270
+ "experiment_key",
271
+ "score_id",
272
+ "tier",
273
+ "level",
274
+ "rule_trace",
275
+ "source",
276
+ "markdown",
277
+ ],
278
+ vals: () => [
279
+ `lead-${RUN}-${randomUUID().slice(0, 8)}`,
280
+ `exp-${RUN}`,
281
+ `score-${RUN}-${randomUUID().slice(0, 8)}`,
282
+ "a",
283
+ 1,
284
+ "{}",
285
+ "{}",
286
+ "# playbook",
287
+ ],
288
+ },
289
+ {
290
+ table: "site_listing_configs",
291
+ cols: ["data", "updated_at"],
292
+ vals: () => ["{}", new Date("2026-09-22T00:00:00Z")],
293
+ },
294
+ ];
295
+
296
+ /** `INSERT INTO t (site_id, workspace_id, …) VALUES ($1, $2, …)`. */
297
+ function insertSql(spec: FenceTable): string {
298
+ const cols = ['"site_id"', '"workspace_id"', ...spec.cols.map((c) => `"${c}"`)];
299
+ const params = cols.map((_, i) => `$${i + 1}`);
300
+ return `INSERT INTO "${spec.table}" (${cols.join(", ")}) VALUES (${params.join(", ")})`;
301
+ }
302
+
303
+ /** The same INSERT with `workspace_id` omitted entirely — the NULL case. */
304
+ function insertSqlWithoutWorkspace(spec: FenceTable): string {
305
+ const cols = ['"site_id"', ...spec.cols.map((c) => `"${c}"`)];
306
+ const params = cols.map((_, i) => `$${i + 1}`);
307
+ return `INSERT INTO "${spec.table}" (${cols.join(", ")}) VALUES (${params.join(", ")})`;
308
+ }
309
+
310
+ describeDb("the #6374 fence fan-out constraints (live DB)", () => {
311
+ let db: Client;
312
+ let workspaceAId: string;
313
+ let workspaceBId: string;
314
+ /** A workspace used ONLY by the cascade proof, so its delete is unblocked. */
315
+ let workspaceCId: string;
316
+ let orgId: string;
317
+
318
+ async function makeWorkspace(label: string): Promise<string> {
319
+ const row = await db.query<{ id: string }>(
320
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
321
+ VALUES ($1, $2, $3, now()) RETURNING "id"`,
322
+ [orgId, `6374 Workspace ${label} ${RUN}`, `6374-ws-${label}-${RUN}`],
323
+ );
324
+ return row.rows[0]!.id;
325
+ }
326
+
327
+ beforeAll(async () => {
328
+ db = new Client({ connectionString: process.env.DATABASE_URL });
329
+ await db.connect();
330
+
331
+ // Two REAL, distinct workspaces — A and B — so a "disagreement" insert
332
+ // names two values that both actually exist, not a real one versus
333
+ // garbage (which the FORMAT CHECK, not the equality CHECK, would catch;
334
+ // the assertions below name the equality constraint for that reason).
335
+ orgId = (
336
+ await db.query<{ id: string }>(
337
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
338
+ VALUES ($1, $2, now()) RETURNING "id"`,
339
+ [`6374 Org ${RUN}`, `6374-org-${RUN}`],
340
+ )
341
+ ).rows[0]!.id;
342
+
343
+ workspaceAId = await makeWorkspace("a");
344
+ workspaceBId = await makeWorkspace("b");
345
+ workspaceCId = await makeWorkspace("c");
346
+ });
347
+
348
+ afterAll(async () => {
349
+ // Every statement here is best-effort and the connection is closed in a
350
+ // `finally`: when an assertion above has already failed because a column
351
+ // or constraint this unit adds is ABSENT, the cleanup statement naming it
352
+ // fails too — and an un-ended `pg` Client is an open handle that hangs
353
+ // the whole Jest run instead of reporting the real failure.
354
+ const cleanup = [
355
+ ...TABLES.map(
356
+ (spec) =>
357
+ [
358
+ `DELETE FROM "${spec.table}" WHERE "site_id" = ANY($1)`,
359
+ [[workspaceAId, workspaceBId, workspaceCId]],
360
+ ] as const,
361
+ ),
362
+ [
363
+ `DELETE FROM "listing_csv_sends" WHERE "listing_id" = $1`,
364
+ [`listing-${RUN}`],
365
+ ] as const,
366
+ [
367
+ `DELETE FROM "deliverable_grants" WHERE "site_id" = ANY($1)`,
368
+ [[workspaceAId, workspaceBId]],
369
+ ] as const,
370
+ [
371
+ `DELETE FROM "site_settings" WHERE "workspace_id" = ANY($1)`,
372
+ [[workspaceAId, workspaceBId]],
373
+ ] as const,
374
+ [`DELETE FROM "workspaces" WHERE "slug" LIKE $1`, [`6374-ws-%-${RUN}`]] as const,
375
+ [`DELETE FROM "organizations" WHERE "slug" = $1`, [`6374-org-${RUN}`]] as const,
376
+ ];
377
+
378
+ try {
379
+ for (const [sql, params] of cleanup) {
380
+ try {
381
+ await db.query(sql, params as unknown[]);
382
+ } catch {
383
+ // Best-effort — see the note above.
384
+ }
385
+ }
386
+ } finally {
387
+ await db.end();
388
+ }
389
+ });
390
+
391
+ describe.each(TABLES.map((spec) => [spec.table, spec] as const))(
392
+ "AC-4 — %s",
393
+ (table, spec) => {
394
+ it("rejects an insert with workspace_id NULL (23502)", async () => {
395
+ await expect(
396
+ db.query(insertSqlWithoutWorkspace(spec), [
397
+ workspaceAId,
398
+ ...spec.vals(),
399
+ ]),
400
+ ).rejects.toMatchObject<PgError>({ code: "23502" });
401
+ });
402
+
403
+ it(`rejects workspace_id <> site_id, BY ${table}_workspace_site_agree (23514)`, async () => {
404
+ await expect(
405
+ db.query(insertSql(spec), [
406
+ workspaceAId,
407
+ workspaceBId,
408
+ ...spec.vals(),
409
+ ]),
410
+ ).rejects.toMatchObject<PgError>({
411
+ code: "23514",
412
+ constraint: `${table}_workspace_site_agree`,
413
+ });
414
+ });
415
+
416
+ it("accepts an insert where workspace_id AGREES with site_id", async () => {
417
+ const result = await db.query(
418
+ `${insertSql(spec)} RETURNING "id"`,
419
+ [workspaceAId, workspaceAId, ...spec.vals()],
420
+ );
421
+ expect(result.rows).toHaveLength(1);
422
+ });
423
+
424
+ it("carries no row with site_id = 'default' after the migration", async () => {
425
+ const result = await db.query<{ count: string }>(
426
+ `SELECT count(*)::text AS count FROM "${table}" WHERE "site_id" = 'default'`,
427
+ );
428
+ expect(result.rows[0]!.count).toBe("0");
429
+ });
430
+ },
431
+ );
432
+
433
+ describe("AC-5 — site_settings is keyed by (workspace_id, key), not by key alone (D3)", () => {
434
+ it("two DIFFERENT workspaces may each hold a feature_flags row", async () => {
435
+ const a = await db.query(
436
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
437
+ VALUES ($1, $2, '{}'::jsonb, now()) RETURNING "id"`,
438
+ [workspaceAId, "feature_flags"],
439
+ );
440
+ const b = await db.query(
441
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
442
+ VALUES ($1, $2, '{}'::jsonb, now()) RETURNING "id"`,
443
+ [workspaceBId, "feature_flags"],
444
+ );
445
+ expect(a.rows).toHaveLength(1);
446
+ expect(b.rows).toHaveLength(1);
447
+ });
448
+
449
+ it("a SECOND feature_flags row in ONE workspace is rejected (23505)", async () => {
450
+ await expect(
451
+ db.query(
452
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
453
+ VALUES ($1, $2, '{}'::jsonb, now())`,
454
+ [workspaceAId, "feature_flags"],
455
+ ),
456
+ ).rejects.toMatchObject<PgError>({ code: "23505" });
457
+ });
458
+
459
+ it("rejects a site_settings row with workspace_id NULL (23502)", async () => {
460
+ await expect(
461
+ db.query(
462
+ `INSERT INTO "site_settings" ("key", "value", "updated_at")
463
+ VALUES ($1, '{}'::jsonb, now())`,
464
+ [`nullws-${RUN}`],
465
+ ),
466
+ ).rejects.toMatchObject<PgError>({ code: "23502" });
467
+ });
468
+ });
469
+
470
+ describe("AC-5 — listing_csv_sends takes NOT NULL + the format CHECK, and no equality CHECK (D2)", () => {
471
+ it("rejects an insert with workspace_id NULL (23502)", async () => {
472
+ await expect(
473
+ db.query(
474
+ `INSERT INTO "listing_csv_sends" ("listing_id", "sent_at", "row_count", "grant_id")
475
+ VALUES ($1, now(), 1, $2)`,
476
+ [`listing-${RUN}`, `grant-${RUN}`],
477
+ ),
478
+ ).rejects.toMatchObject<PgError>({ code: "23502" });
479
+ });
480
+
481
+ it("accepts an insert stamped with a well-formed workspace_id", async () => {
482
+ const result = await db.query(
483
+ `INSERT INTO "listing_csv_sends"
484
+ ("listing_id", "workspace_id", "sent_at", "row_count", "grant_id")
485
+ VALUES ($1, $2, now(), 1, $3) RETURNING "id"`,
486
+ [`listing-${RUN}`, workspaceAId, `grant-${RUN}`],
487
+ );
488
+ expect(result.rows).toHaveLength(1);
489
+ });
490
+
491
+ it("has NO <table>_workspace_site_agree constraint — it carries no site_id (D2)", async () => {
492
+ const result = await db.query<{ conname: string }>(
493
+ `SELECT c.conname FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid
494
+ WHERE t.relname = 'listing_csv_sends'
495
+ AND c.conname = 'listing_csv_sends_workspace_site_agree'`,
496
+ );
497
+ expect(result.rows).toHaveLength(0);
498
+ });
499
+ });
500
+
501
+ describe("AC-5 — deliverable_grants stays NULL-tolerant but never disagreeing (D4)", () => {
502
+ it("ACCEPTS a row with workspace_id NULL — the serve path's fail-closed sentinel", async () => {
503
+ const result = await db.query(
504
+ `INSERT INTO "deliverable_grants" ("site_id", "asset_id", "lead_id", "session_id")
505
+ VALUES ($1, $2, $3, $4) RETURNING "id"`,
506
+ [
507
+ workspaceAId,
508
+ `dasset-${RUN}`,
509
+ `lead-${RUN}`,
510
+ `sess-${RUN}-${randomUUID().slice(0, 8)}`,
511
+ ],
512
+ );
513
+ expect(result.rows).toHaveLength(1);
514
+ });
515
+
516
+ it("rejects a STAMPED row whose workspace_id differs from its site_id (23514)", async () => {
517
+ await expect(
518
+ db.query(
519
+ `INSERT INTO "deliverable_grants"
520
+ ("site_id", "workspace_id", "asset_id", "lead_id", "session_id")
521
+ VALUES ($1, $2, $3, $4, $5)`,
522
+ [
523
+ workspaceAId,
524
+ workspaceBId,
525
+ `asset-${RUN}`,
526
+ `lead-${RUN}`,
527
+ `sess-${RUN}-${randomUUID().slice(0, 8)}`,
528
+ ],
529
+ ),
530
+ ).rejects.toMatchObject<PgError>({
531
+ code: "23514",
532
+ constraint: "deliverable_grants_workspace_site_agree",
533
+ });
534
+ });
535
+
536
+ it("accepts a STAMPED row whose workspace_id agrees with its site_id", async () => {
537
+ const result = await db.query(
538
+ `INSERT INTO "deliverable_grants"
539
+ ("site_id", "workspace_id", "asset_id", "lead_id", "session_id")
540
+ VALUES ($1, $1, $2, $3, $4) RETURNING "id"`,
541
+ [
542
+ workspaceAId,
543
+ `asset-${RUN}`,
544
+ `lead-${RUN}`,
545
+ `sess-${RUN}-${randomUUID().slice(0, 8)}`,
546
+ ],
547
+ );
548
+ expect(result.rows).toHaveLength(1);
549
+ });
550
+ });
551
+
552
+ describe("AC-14 — site_settings: abort, never guess (the key carries the tenant)", () => {
553
+ /** The sentinel Organization the sentinel workspace must hang off. */
554
+ const SENTINEL_ORGANIZATION_ID = "org_00000000-0000-7000-8000-000000000001";
555
+
556
+ // Every case runs inside a transaction that is ROLLED BACK, and each first
557
+ // drops NOT NULL on `workspace_id` so the PRE-migration row shape (an
558
+ // unattributed row) can exist at all. Nothing escapes the rollback.
559
+ async function withPreMigrationShape<T>(
560
+ body: () => Promise<T>,
561
+ ): Promise<T> {
562
+ await db.query("BEGIN");
563
+ try {
564
+ await db.query(
565
+ `ALTER TABLE "site_settings" ALTER COLUMN "workspace_id" DROP NOT NULL`,
566
+ );
567
+ return await body();
568
+ } finally {
569
+ await db.query("ROLLBACK");
570
+ }
571
+ }
572
+
573
+ /**
574
+ * The sentinel UPDATE joins to `workspaces`, so a database without the
575
+ * sentinel row backfills NOTHING and the post-backfill assertion aborts.
576
+ * A fresh `prisma migrate deploy` seeds no sentinel, so the non-abort
577
+ * cases create it here — inside the same rolled-back transaction.
578
+ */
579
+ async function ensureSentinelWorkspace(): Promise<void> {
580
+ await db.query(
581
+ `INSERT INTO "organizations" ("id", "name", "slug", "updated_at")
582
+ VALUES ($1, $2, $3, now()) ON CONFLICT DO NOTHING`,
583
+ [SENTINEL_ORGANIZATION_ID, `Sentinel Org ${RUN}`, `sentinel-org-${RUN}`],
584
+ );
585
+ await db.query(
586
+ `INSERT INTO "workspaces" ("id", "organization_id", "name", "slug", "updated_at")
587
+ VALUES ($1, $2, $3, $4, now()) ON CONFLICT DO NOTHING`,
588
+ [
589
+ SENTINEL_WORKSPACE_ID,
590
+ SENTINEL_ORGANIZATION_ID,
591
+ `Sentinel WS ${RUN}`,
592
+ `sentinel-ws-${RUN}`,
593
+ ],
594
+ );
595
+ }
596
+
597
+ /** The `workspace_id` the one row carrying `key` ended up attributed to. */
598
+ async function attributedWorkspaceOf(key: string): Promise<string | null> {
599
+ const rows = await db.query<{ workspace_id: string | null }>(
600
+ `SELECT "workspace_id" FROM "site_settings" WHERE "key" = $1`,
601
+ [key],
602
+ );
603
+ expect(rows.rows).toHaveLength(1);
604
+ return rows.rows[0]!.workspace_id;
605
+ }
606
+
607
+ it("(a) ABORTS on an unattributed row whose key embeds a NON-sentinel workspace id", async () => {
608
+ await withPreMigrationShape(async () => {
609
+ await ensureSentinelWorkspace();
610
+ await db.query(
611
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
612
+ VALUES (NULL, $1, '{}'::jsonb, now())`,
613
+ [`commerce.dunning_policy:${workspaceAId}`],
614
+ );
615
+
616
+ // workspaceAId is a real, well-formed, NON-sentinel wksp id — exactly
617
+ // the row the sentinel assignment would misfile. The guard aborts
618
+ // BEFORE the UPDATE, so a sentinel workspace being available here
619
+ // changes nothing.
620
+ // No explicit type argument here: `PgError` declares no `message`,
621
+ // and pinning one would force a cast. The matcher checks both the
622
+ // SQLSTATE and that the exception names the table it is about, so a
623
+ // P0001 raised by some other block cannot satisfy this.
624
+ await expect(db.query(siteSettingsGuardSql())).rejects.toMatchObject({
625
+ code: "P0001",
626
+ message: expect.stringContaining("site_settings"),
627
+ });
628
+ });
629
+ });
630
+
631
+ it("(b) ATTRIBUTES an unattributed row whose key embeds NO workspace id to the sentinel", async () => {
632
+ const aliasKey = `commerce.dunning_policy:default-${RUN}`;
633
+ const plainKey = `feature_flags-${RUN}`;
634
+ await withPreMigrationShape(async () => {
635
+ await ensureSentinelWorkspace();
636
+ // Both shapes the guard must let through: the bare alias the
637
+ // single-tenant build writes, and an ordinary config key naming no
638
+ // site at all.
639
+ await db.query(
640
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
641
+ VALUES (NULL, $1, '{}'::jsonb, now()), (NULL, $2, '{}'::jsonb, now())`,
642
+ [aliasKey, plainKey],
643
+ );
644
+
645
+ await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
646
+
647
+ // The slice ran guard -> UPDATE -> assertion, so these are ATTRIBUTED,
648
+ // not merely un-rejected.
649
+ expect(await attributedWorkspaceOf(aliasKey)).toBe(SENTINEL_WORKSPACE_ID);
650
+ expect(await attributedWorkspaceOf(plainKey)).toBe(SENTINEL_WORKSPACE_ID);
651
+ });
652
+ });
653
+
654
+ it("(c) ATTRIBUTES an unattributed row whose key embeds the SENTINEL id to the sentinel", async () => {
655
+ const key = `commerce.dunning_policy:${SENTINEL_WORKSPACE_ID}`;
656
+ await withPreMigrationShape(async () => {
657
+ await ensureSentinelWorkspace();
658
+ await db.query(
659
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
660
+ VALUES (NULL, $1, '{}'::jsonb, now())`,
661
+ [key],
662
+ );
663
+
664
+ await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
665
+
666
+ // A key already naming the sentinel is not a misfiling risk — it
667
+ // backfills to exactly the workspace its key names.
668
+ expect(await attributedWorkspaceOf(key)).toBe(SENTINEL_WORKSPACE_ID);
669
+ });
670
+ });
671
+
672
+ it("(d) is INERT on an already-migrated database — the verbatim re-run stays a no-op (NFR2)", async () => {
673
+ // No transaction and no DROP NOT NULL: this is the real, migrated table,
674
+ // where every row carries a workspace_id. The `IS NULL` guard makes the
675
+ // check match nothing, the UPDATE match nothing, and the assertion pass
676
+ // — which is what keeps AC-3's re-run clean even on a database whose
677
+ // keys embed non-sentinel ids.
678
+ const key = `commerce.dunning_policy:${workspaceAId}`;
679
+ await db.query(
680
+ `INSERT INTO "site_settings" ("workspace_id", "key", "value", "updated_at")
681
+ VALUES ($1, $2, '{}'::jsonb, now())`,
682
+ [workspaceAId, key],
683
+ );
684
+
685
+ await expect(db.query(siteSettingsGuardSql())).resolves.toBeDefined();
686
+
687
+ // Untouched: still its OWN workspace, never rewritten to the sentinel.
688
+ expect(await attributedWorkspaceOf(key)).toBe(workspaceAId);
689
+ });
690
+ });
691
+
692
+ describe("AC-5 — the fence is a CHECK, never a foreign key (NFR1)", () => {
693
+ it("deleting a workspaces row leaves that workspace's consent_grants rows in place", async () => {
694
+ await db.query(
695
+ `INSERT INTO "consent_grants"
696
+ ("site_id", "workspace_id", "subject_type", "subject_id", "purpose_id",
697
+ "granted", "locale", "statement", "statement_hash")
698
+ VALUES ($1, $1, 'subscriber', $2, $3, true, 'en', 'I agree', $4)`,
699
+ [workspaceCId, `cascade-${RUN}`, `purpose-${RUN}`, `hash-${RUN}`],
700
+ );
701
+
702
+ await db.query(`DELETE FROM "workspaces" WHERE "id" = $1`, [workspaceCId]);
703
+
704
+ const remaining = await db.query<{ count: string }>(
705
+ `SELECT count(*)::text AS count FROM "consent_grants" WHERE "site_id" = $1`,
706
+ [workspaceCId],
707
+ );
708
+ expect(remaining.rows[0]!.count).toBe("1");
709
+ });
710
+ });
711
+ });