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,79 @@
1
+ /**
2
+ * @file appointment-create-probe.ts
3
+ *
4
+ * LIVE-CLIENT probe for `appointment.db.test.ts` (#6162). Runs `prisma.
5
+ * appointment.create()` for real, against a real (migrated) Postgres,
6
+ * through the REAL generated client — the exact runtime path that surfaced
7
+ * the bug this test guards ("column noShow does not exist" once
8
+ * `Appointment.noShow` lost its `@map("no_show")`).
9
+ *
10
+ * WHY A SEPARATE SCRIPT, RUN VIA `tsx` AS A SUBPROCESS, RATHER THAN IMPORTED
11
+ * DIRECTLY BY THE JEST TEST. `database/generated/client/client.ts` is
12
+ * generator-emitted, uses `import.meta.url`, and this package's `jest.
13
+ * config.cjs` transforms every test file through `ts-jest` targeting
14
+ * CommonJS — which cannot load an ESM-only module (see `prefixed-ids.db.
15
+ * test.ts`'s header for the same constraint). `tsx` runs this file as real
16
+ * ESM/TS with no such restriction, exactly as `seed-test.ts` already does
17
+ * for the same client (`pnpm --filter @working-theory/database db:seed:
18
+ * test`). The jest test spawns this script and asserts on its JSON stdout.
19
+ *
20
+ * Reads DATABASE_URL from the environment (the caller's `spawnSync` inherits
21
+ * `process.env`; `dotenv/config` covers a bare local run against `database/
22
+ * .env`). Creates one Appointment row with `noShow: true`, prints it as JSON,
23
+ * deletes it, and exits 0. On any Prisma error (e.g. the pre-fix "column
24
+ * noShow does not exist"), prints the error and exits 1 — the RED signal
25
+ * `appointment.db.test.ts` asserts against.
26
+ */
27
+ import "dotenv/config";
28
+
29
+ import { PrismaPg } from "@prisma/adapter-pg";
30
+
31
+ import { PrismaClient } from "../../generated/client/client.js";
32
+
33
+ const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
34
+ const prisma = new PrismaClient({ adapter });
35
+
36
+ // A fresh providerBookingUid per run — the unique key is
37
+ // (siteId, provider, providerBookingUid), and this probe's site never
38
+ // appears in any real fixture, so collision across concurrent runs would
39
+ // only ever come from two probes racing within the same millisecond.
40
+ //
41
+ // #6374 — the id SHAPE is now load-bearing, where before any string did.
42
+ // `appointments` joined the tenancy fence, so the row must satisfy
43
+ // `appointments_workspace_id_format` (a well-formed `wksp_`+UUIDv7) AND
44
+ // `appointments_workspace_site_agree` (workspace_id = site_id). This probe
45
+ // still names a site that appears in no real fixture — the last group is a
46
+ // literal marker, not a real workspace — but it must now be SHAPED like one.
47
+ // Nothing here depends on the workspace existing: the fence is a CHECK, never
48
+ // a foreign key (ADR-0009 invariant #1).
49
+ const SITE_ID = "wksp_00000000-0000-7000-8000-00000000f1f1";
50
+ const PROVIDER_BOOKING_UID = `probe-${Date.now()}-${process.pid}`;
51
+
52
+ async function main(): Promise<void> {
53
+ const row = await prisma.appointment.create({
54
+ data: {
55
+ siteId: SITE_ID,
56
+ // #6374 — equal to `siteId` by construction, which is exactly what
57
+ // `appointments_workspace_site_agree` enforces at the database.
58
+ workspaceId: SITE_ID,
59
+ providerBookingUid: PROVIDER_BOOKING_UID,
60
+ startTime: new Date("2026-09-20T10:00:00.000Z"),
61
+ endTime: new Date("2026-09-20T10:15:00.000Z"),
62
+ attendeeName: "Appointment Probe",
63
+ attendeeEmail: "appointment-probe@example.invalid",
64
+ status: "confirmed",
65
+ noShow: true,
66
+ noShowEventAt: new Date("2026-09-20T10:20:00.000Z"),
67
+ lifecycleEventAt: new Date("2026-09-18T00:00:00.000Z"),
68
+ },
69
+ });
70
+ process.stdout.write(JSON.stringify(row));
71
+ await prisma.appointment.delete({ where: { id: row.id } });
72
+ await prisma.$disconnect();
73
+ }
74
+
75
+ main().catch(async (err: unknown) => {
76
+ console.error(err instanceof Error ? err.message : String(err));
77
+ await prisma.$disconnect();
78
+ process.exitCode = 1;
79
+ });
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @file project-listing-consent-probe.ts
3
+ *
4
+ * LIVE-CLIENT probe for `project-listing-consent-at.db.test.ts` (#6171). Runs
5
+ * `prisma.projectListing.create()` / `.findUniqueOrThrow()` for real, against a
6
+ * real (migrated) Postgres, through the REAL generated client — spawned via
7
+ * `tsx` as a subprocess for the same reason `appointment-create-probe.ts`
8
+ * documents: `database/generated/client/client.ts` is generator-emitted,
9
+ * uses `import.meta.url`, and this package's `jest.config.cjs` transforms
10
+ * test files through `ts-jest` targeting CommonJS, which cannot load an
11
+ * ESM-only module.
12
+ *
13
+ * The bug this guards: `ProjectListing.consentAt` carried no
14
+ * `@db.Timestamptz(3)` — Prisma defaults an unannotated `DateTime` on
15
+ * Postgres to `TIMESTAMP(3)` (no time zone) — while the migration that added
16
+ * the column (`20260909180000_listing_draft_and_fields`) created it as the
17
+ * genuinely tz-aware `TIMESTAMPTZ`. This probe connects with the Postgres
18
+ * session clock pinned to a non-UTC, half-hour-offset zone
19
+ * (`Asia/Kolkata`, UTC+5:30) specifically because a naive wall-clock
20
+ * misinterpretation would shift the read-back instant by a non-round amount
21
+ * impossible to mistake for a coincidence, and writes a `consentAt` instant
22
+ * chosen to fall on a different Kolkata calendar DATE than its UTC date
23
+ * (18:30 UTC == 00:00 +5:30 the next day) so a date-only slip is equally
24
+ * visible.
25
+ *
26
+ * Reads DATABASE_URL from the environment. Creates one ProjectListing row,
27
+ * reads it back via a fresh `findUniqueOrThrow`, prints both timestamps plus
28
+ * the live column's `information_schema` type as JSON, deletes the row, and
29
+ * exits 0. On any Prisma error, prints the error and exits 1.
30
+ */
31
+ import "dotenv/config";
32
+
33
+ import { PrismaPg } from "@prisma/adapter-pg";
34
+
35
+ import { PrismaClient } from "../../generated/client/client.js";
36
+ import { DEFAULT_WORKSPACE_ID } from "../../tenancy.js";
37
+
38
+ // Postgres session timezone pinned to a non-UTC, half-hour offset so any
39
+ // wall-clock (mis)interpretation of the write or read path shows up as a
40
+ // non-round, unmistakable shift rather than a suspiciously-clean hour delta.
41
+ const adapter = new PrismaPg({
42
+ connectionString: process.env.DATABASE_URL,
43
+ options: "-c timezone=Asia/Kolkata",
44
+ });
45
+ const prisma = new PrismaClient({ adapter });
46
+
47
+ // #6254 (the tenancy-fence keystone) retrofitted ProjectListing.workspaceId
48
+ // to NOT NULL plus `project_listings_workspace_site_agree CHECK (workspace_id
49
+ // = site_id)` — siteId must therefore be a well-formed `wksp_` id equal to
50
+ // workspaceId. No production write path resolves an arbitrary literal like
51
+ // the prior `"st_consent-tz-probe"` here (there is no FK to `workspaces`, so
52
+ // the reserved sentinel need not exist as a real row — only be well-shaped),
53
+ // so the sentinel default workspace id stands in for both columns.
54
+ const SITE_ID = DEFAULT_WORKSPACE_ID;
55
+ // 18:30 UTC == 2026-09-21T00:00:00+05:30 — a different calendar date in the
56
+ // session's pinned zone than in UTC, so a naive-offset bug shows up as either
57
+ // a shifted instant OR a shifted date, whichever the bug takes.
58
+ const CONSENT_AT = new Date("2026-09-20T18:30:00.000Z");
59
+
60
+ async function main(): Promise<void> {
61
+ const created = await prisma.projectListing.create({
62
+ data: {
63
+ siteId: SITE_ID,
64
+ workspaceId: DEFAULT_WORKSPACE_ID,
65
+ founderName: "Consent TZ Probe",
66
+ founderEmail: "consent-tz-probe@example.invalid",
67
+ status: "draft",
68
+ consentAt: CONSENT_AT,
69
+ },
70
+ });
71
+
72
+ // A second, independent read — through a fresh query, not the object
73
+ // `create()` already returned — so the proof covers the READ path too.
74
+ const reread = await prisma.projectListing.findUniqueOrThrow({
75
+ where: { id: created.id },
76
+ });
77
+
78
+ const columnType = await prisma.$queryRaw<Array<{ data_type: string }>>`
79
+ SELECT data_type FROM information_schema.columns
80
+ WHERE table_name = 'project_listings' AND column_name = 'consent_at'
81
+ `;
82
+
83
+ process.stdout.write(
84
+ JSON.stringify({
85
+ writtenIso: CONSENT_AT.toISOString(),
86
+ createdConsentAtIso: created.consentAt.toISOString(),
87
+ rereadConsentAtIso: reread.consentAt.toISOString(),
88
+ liveColumnDataType: columnType[0]?.data_type ?? null,
89
+ }),
90
+ );
91
+
92
+ await prisma.projectListing.delete({ where: { id: created.id } });
93
+ await prisma.$disconnect();
94
+ }
95
+
96
+ main().catch(async (err: unknown) => {
97
+ console.error(err instanceof Error ? err.message : String(err));
98
+ await prisma.$disconnect();
99
+ process.exitCode = 1;
100
+ });
@@ -0,0 +1,263 @@
1
+ /**
2
+ * @file flow-workspace-key.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the #6155 backfill (spec § 8.4, AC-1). Requires
5
+ * DATABASE_URL pointing at a Postgres with the migrations applied (`prisma
6
+ * migrate deploy`); the whole suite SKIPS when DATABASE_URL is unset,
7
+ * matching `prefixed-ids.db.test.ts` / `artifact-content-store.db.test.ts`
8
+ * and keeping the workspace Jest run DB-free.
9
+ *
10
+ * Raw SQL through `pg`, for the same reason those two files give: the
11
+ * generated Prisma client is ESM-only and cannot load under this package's
12
+ * CJS ts-jest transform.
13
+ *
14
+ * RECONCILED WITH #6254 (the tenancy-fence keystone, #6373 AC-4). The
15
+ * ORIGINAL "RE-RUN TECHNIQUE" this file used — insert fresh
16
+ * `workspace_id IS NULL` rows, then re-execute this migration's own SQL
17
+ * verbatim to prove its backfill logic — assumed a row COULD land with
18
+ * `workspace_id` unset. That assumption is no longer true on current main:
19
+ * `database/migrations/20260922120000_tenancy_fence` retrofits
20
+ * `flow_definitions.workspace_id` / `flow_sessions.workspace_id` to NOT
21
+ * NULL, plus a `CHECK (workspace_id = site_id)` equality and a
22
+ * `CHECK (workspace_id ~ '^wksp_...')` format constraint on BOTH tables.
23
+ * Together those three constraints mean:
24
+ *
25
+ * - `site_id` must now ALWAYS be a well-formed `wksp_` id equal to
26
+ * `workspace_id` — the legacy alias `"default"` (never `wksp_`-shaped)
27
+ * and an arbitrary unresolvable string can no longer be written at all,
28
+ * with or without `workspace_id` supplied. Attempting either now aborts
29
+ * the write outright (a NOT NULL / CHECK violation), rather than landing
30
+ * a row with `workspace_id` left NULL — this is the fence doing exactly
31
+ * what its own migration text says an unresolvable row must do ("abort
32
+ * the transaction rather than pass through silently").
33
+ * - The `"default"` alias itself is resolved at the APPLICATION layer
34
+ * before a write reaches the database now (the fence migration's own
35
+ * header: "the application-code half of D1a... is fixed in the same
36
+ * PR") — a fresh row's `site_id` is never the literal string `"default"`
37
+ * on live main, only ever the resolved `DEFAULT_WORKSPACE_ID` sentinel.
38
+ *
39
+ * So this suite's three cases are reconciled to what main actually does:
40
+ *
41
+ * 1. A row whose `site_id` names a real workspace carries a matching
42
+ * `workspace_id` from the moment it is written (the shape the OLD
43
+ * backfill used to produce is now enforced at write time), and is
44
+ * readable through the shape `tenantDb(tenant).flows.byId` /
45
+ * `.flowSessions.byId` use (`findFirst({ id, workspaceId })`). The
46
+ * shipped `20260919120000_flow_workspace_key` migration is STILL
47
+ * re-run verbatim afterward — every `workspace_id IS NULL` row it
48
+ * would touch is now unreachable, so this proves the migration stays
49
+ * safely re-runnable (NFR3) rather than corrupting an already-correct
50
+ * row, not that it backfills one.
51
+ * 2. A row whose `site_id` matches no workspace can no longer be created
52
+ * at all — inserting it (with `workspace_id` omitted, exactly the
53
+ * pre-fence shape) is REJECTED by the NOT NULL constraint the fence
54
+ * added, never landed unset.
55
+ * 3. A row whose `site_id` is the resolved sentinel `DEFAULT_WORKSPACE_ID`
56
+ * — what the legacy `"default"` alias now resolves to before any write
57
+ * reaches the database — carries a matching `workspace_id` from the
58
+ * moment it is written, exactly like case 1.
59
+ * 4. The literal sentinel id written into the migration's own SQL text
60
+ * equals `DEFAULT_WORKSPACE_ID` from `../tenancy.js` — read from the
61
+ * `.sql` file and compared, so the two cannot drift apart (§ 5.3
62
+ * step 5). Unaffected by the fence — no DB write involved.
63
+ */
64
+ import { randomUUID } from "node:crypto";
65
+ import { readFileSync } from "node:fs";
66
+ import { join } from "node:path";
67
+
68
+ import { Client } from "pg";
69
+
70
+ import { DEFAULT_WORKSPACE_ID } from "../tenancy.js";
71
+
72
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
73
+
74
+ /** A run-unique suffix so repeated local runs never collide. */
75
+ const RUN = randomUUID().slice(0, 8);
76
+
77
+ const MIGRATION_SQL_PATH = join(
78
+ __dirname,
79
+ "..",
80
+ "migrations",
81
+ "20260919120000_flow_workspace_key",
82
+ "migration.sql",
83
+ );
84
+ const MIGRATION_SQL = readFileSync(MIGRATION_SQL_PATH, "utf-8");
85
+
86
+ describeDb(
87
+ "flow_definitions / flow_sessions workspace_id backfill (#6155, live DB)",
88
+ () => {
89
+ let db: Client;
90
+ let otherWorkspaceId: string;
91
+ const noSuchSite = `no-such-site-${RUN}`;
92
+
93
+ const definitionIds: string[] = [];
94
+ const sessionIds: string[] = [];
95
+
96
+ beforeAll(async () => {
97
+ db = new Client({ connectionString: process.env.DATABASE_URL });
98
+ await db.connect();
99
+
100
+ // A REAL, non-sentinel workspace — the "pinned, resolvable" case (rule 3:
101
+ // exact `workspaces.id` equality, nothing to do with the sentinel).
102
+ const org = (
103
+ await db.query<{ id: string }>(
104
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
105
+ VALUES ($1, $2, now()) RETURNING "id"`,
106
+ [`6155 Org ${RUN}`, `6155-org-${RUN}`],
107
+ )
108
+ ).rows[0]!;
109
+ otherWorkspaceId = (
110
+ await db.query<{ id: string }>(
111
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
112
+ VALUES ($1, $2, $3, now()) RETURNING "id"`,
113
+ [org.id, `6155 Workspace ${RUN}`, `6155-ws-${RUN}`],
114
+ )
115
+ ).rows[0]!.id;
116
+
117
+ // One FlowDefinition / FlowSession pair, carrying its correct
118
+ // workspace_id from the moment of insert — the fence's NOT NULL means
119
+ // there is no longer a `workspace_id IS NULL` pre-state to construct
120
+ // for a row that CAN legitimately exist (see the file header). `title`
121
+ // / `status` are also NOT NULL as of the same-day
122
+ // `20260919130000_flow_definition_admin_columns` migration (no column
123
+ // default, by that migration's own design — a nameless/unpublished
124
+ // magnet must never enter the table silently) — both supplied here so
125
+ // the ONLY thing under test is the workspace_id/site_id shape.
126
+ const insertPair = async (siteId: string): Promise<void> => {
127
+ const def = (
128
+ await db.query<{ id: string }>(
129
+ `INSERT INTO "flow_definitions"
130
+ ("site_id", "workspace_id", "slug", "config", "title", "status", "updated_at")
131
+ VALUES ($1, $1, $2, '{}'::jsonb, 'Test', 'live', now()) RETURNING "id"`,
132
+ [siteId, `6155-${RUN}-${siteId}`],
133
+ )
134
+ ).rows[0]!;
135
+ definitionIds.push(def.id);
136
+
137
+ const sess = (
138
+ await db.query<{ id: string }>(
139
+ `INSERT INTO "flow_sessions"
140
+ ("site_id", "workspace_id", "flow_id", "flow_slug", "config_snapshot", "updated_at")
141
+ VALUES ($1, $1, $2, $3, '{}'::jsonb, now()) RETURNING "id"`,
142
+ [siteId, def.id, `6155-${RUN}-${siteId}`],
143
+ )
144
+ ).rows[0]!;
145
+ sessionIds.push(sess.id);
146
+ };
147
+
148
+ // Case 1's row FIRST, then the migration re-run, THEN case 3's row —
149
+ // deliberately sequenced, not merged into one loop. The migration's
150
+ // OWN post-assertions (re-read verbatim below) reject ANY row whose
151
+ // workspace_id names a workspace absent from "workspaces" ("nothing
152
+ // guessed"); `DEFAULT_WORKSPACE_ID` has no corresponding "workspaces"
153
+ // row on a freshly-migrated-but-unseeded database (that row is
154
+ // `database/seed.ts`'s job, not a migration's), so the re-run must
155
+ // happen BEFORE case 3's row exists, or it would trip that guard for
156
+ // a row it never touched.
157
+ await insertPair(otherWorkspaceId);
158
+
159
+ // Re-run the shipped migration file verbatim (NFR3 — still
160
+ // re-runnable). Every `workspace_id IS NULL` row it would touch is now
161
+ // unreachable (the fence's NOT NULL forbids one from ever existing),
162
+ // so this is a no-op on the row just inserted — proven by the
163
+ // "unchanged after re-run" assertion in the first `it()` block below,
164
+ // not a backfill.
165
+ await db.query(MIGRATION_SQL);
166
+
167
+ await insertPair(DEFAULT_WORKSPACE_ID);
168
+ });
169
+
170
+ afterAll(async () => {
171
+ await db.query(`DELETE FROM "flow_sessions" WHERE "flow_slug" LIKE $1`, [
172
+ `6155-${RUN}-%`,
173
+ ]);
174
+ await db.query(`DELETE FROM "flow_definitions" WHERE "slug" LIKE $1`, [
175
+ `6155-${RUN}-%`,
176
+ ]);
177
+ await db.query(`DELETE FROM "workspaces" WHERE "slug" = $1`, [
178
+ `6155-ws-${RUN}`,
179
+ ]);
180
+ await db.query(`DELETE FROM "organizations" WHERE "slug" = $1`, [
181
+ `6155-org-${RUN}`,
182
+ ]);
183
+ await db.end();
184
+ });
185
+
186
+ it("a row whose site_id names a real workspace carries a matching workspace_id, unchanged by a migration re-run, and is readable through the accessor's own query shape", async () => {
187
+ const [defId, sessId] = [definitionIds[0]!, sessionIds[0]!];
188
+
189
+ const def = await db.query<{ workspace_id: string | null }>(
190
+ `SELECT "workspace_id" FROM "flow_definitions" WHERE "id" = $1`,
191
+ [defId],
192
+ );
193
+ expect(def.rows[0]!.workspace_id).toBe(otherWorkspaceId);
194
+
195
+ // The shape tenantDb(tenant).flows.byId uses: findFirst({ id, workspaceId }).
196
+ const readable = await db.query(
197
+ `SELECT "id" FROM "flow_definitions" WHERE "id" = $1 AND "workspace_id" = $2`,
198
+ [defId, otherWorkspaceId],
199
+ );
200
+ expect(readable.rows).toHaveLength(1);
201
+
202
+ const sess = await db.query<{ workspace_id: string | null }>(
203
+ `SELECT "workspace_id" FROM "flow_sessions" WHERE "id" = $1`,
204
+ [sessId],
205
+ );
206
+ expect(sess.rows[0]!.workspace_id).toBe(otherWorkspaceId);
207
+
208
+ const sessReadable = await db.query(
209
+ `SELECT "id" FROM "flow_sessions" WHERE "id" = $1 AND "workspace_id" = $2`,
210
+ [sessId, otherWorkspaceId],
211
+ );
212
+ expect(sessReadable.rows).toHaveLength(1);
213
+ });
214
+
215
+ it("a row whose site_id matches no workspace can no longer be created — the fence retrofit rejects it outright, never leaving it unset", async () => {
216
+ // The EXACT pre-fence shape (no workspace_id supplied) — proving that
217
+ // what used to land a `workspace_id IS NULL` row is now rejected
218
+ // before it can, on both tables the #6155 backfill covered.
219
+ await expect(
220
+ db.query(
221
+ `INSERT INTO "flow_definitions" ("site_id", "slug", "config", "title", "status", "updated_at")
222
+ VALUES ($1, $2, '{}'::jsonb, 'Test', 'live', now())`,
223
+ [noSuchSite, `6155-${RUN}-abort-def`],
224
+ ),
225
+ ).rejects.toThrow(/workspace_id/);
226
+
227
+ await expect(
228
+ db.query(
229
+ `INSERT INTO "flow_sessions" ("site_id", "flow_id", "flow_slug", "config_snapshot", "updated_at")
230
+ VALUES ($1, $2, $3, '{}'::jsonb, now())`,
231
+ [noSuchSite, definitionIds[0]!, `6155-${RUN}-abort-sess`],
232
+ ),
233
+ ).rejects.toThrow(/workspace_id/);
234
+ });
235
+
236
+ it("a row whose site_id is the resolved sentinel DEFAULT_WORKSPACE_ID carries a matching workspace_id (D2 rule 2's write-time destination)", async () => {
237
+ const [defId, sessId] = [definitionIds[1]!, sessionIds[1]!];
238
+
239
+ const def = await db.query<{ workspace_id: string | null }>(
240
+ `SELECT "workspace_id" FROM "flow_definitions" WHERE "id" = $1`,
241
+ [defId],
242
+ );
243
+ expect(def.rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
244
+
245
+ const sess = await db.query<{ workspace_id: string | null }>(
246
+ `SELECT "workspace_id" FROM "flow_sessions" WHERE "id" = $1`,
247
+ [sessId],
248
+ );
249
+ expect(sess.rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
250
+ });
251
+
252
+ it("the migration's own literal sentinel id equals DEFAULT_WORKSPACE_ID — the two cannot drift apart", () => {
253
+ // SQL cannot import database/tenancy.ts (§ 5.3 step 5), so the migration
254
+ // carries the sentinel as a literal. Pin it against the exported
255
+ // constant by reading the shipped file, not a re-typed copy of it.
256
+ const occurrences = MIGRATION_SQL.match(/'wksp_[0-9a-f-]+'/g) ?? [];
257
+ expect(occurrences.length).toBeGreaterThan(0);
258
+ for (const literal of occurrences) {
259
+ expect(literal).toBe(`'${DEFAULT_WORKSPACE_ID}'`);
260
+ }
261
+ });
262
+ },
263
+ );
@@ -25,6 +25,9 @@
25
25
  * 4. The relation-free scope columns (workspaceId refs) are CHECKed too.
26
26
  * 5. The reserved-range sentinel ids are admitted by the CHECKs.
27
27
  */
28
+ import { readdirSync, readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+
28
31
  import { Client } from "pg";
29
32
 
30
33
  import { DEFAULT_ORG_ID, DEFAULT_WORKSPACE_ID } from "../tenancy.js";
@@ -34,16 +37,82 @@ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
34
37
  const V7_RE =
35
38
  /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
36
39
 
40
+ /**
41
+ * Every distinct `_format` CHECK constraint name declared across
42
+ * `database/migrations/*\/migration.sql` — both the inline `CREATE TABLE`
43
+ * form and the `ALTER TABLE ... ADD CONSTRAINT` form (including the
44
+ * existence-guarded `DO $$` blocks later migrations use for a NULLABLE
45
+ * scope column, e.g. 20260919120000_flow_workspace_key). Comment lines are
46
+ * skipped so a rollback note that merely mentions a constraint name doesn't
47
+ * count as a second declaration.
48
+ *
49
+ * Derived from the migration source rather than hard-coded (#6119): a
50
+ * hard-coded literal here goes stale the moment a later migration adds a
51
+ * new prefixed-id model or scope column, failing this test for a reason
52
+ * that has nothing to do with the database under test. Deriving it instead
53
+ * still catches the real defect this test guards — the live database
54
+ * missing a CHECK its own migrations declare (a partial/failed deploy,
55
+ * migrations applied out of order, one silently dropped) — because the
56
+ * expected count comes from the repo's migration files while the actual
57
+ * count comes from a live query against the database those migrations were
58
+ * run against; the two are independent unless the migrations actually ran
59
+ * to completion.
60
+ */
61
+ function declaredFormatCheckCount(): number {
62
+ const migrationsDir = join(__dirname, "..", "migrations");
63
+ const names = new Set<string>();
64
+ for (const entry of readdirSync(migrationsDir, { withFileTypes: true })) {
65
+ if (!entry.isDirectory()) continue;
66
+ const sqlPath = join(migrationsDir, entry.name, "migration.sql");
67
+ let sql: string;
68
+ try {
69
+ sql = readFileSync(sqlPath, "utf-8");
70
+ } catch {
71
+ continue;
72
+ }
73
+ for (const line of sql.split("\n")) {
74
+ if (line.trim().startsWith("--")) continue;
75
+ const match = line.match(/CONSTRAINT\s+"([a-z_]+_format)"/);
76
+ if (match) names.add(match[1]);
77
+ }
78
+ }
79
+ return names.size;
80
+ }
81
+
37
82
  describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
38
83
  let db: Client;
84
+ // #6254 (the tenancy-fence keystone) retrofitted `leads.workspace_id` to
85
+ // NOT NULL plus `leads_workspace_site_agree CHECK (workspace_id =
86
+ // site_id)` and `leads_workspace_id_format CHECK (workspace_id ~
87
+ // '^wksp_...')`. Every "leads" insert below now needs a well-formed
88
+ // `wksp_` id in BOTH columns — a real seeded workspace (mirroring
89
+ // `composite-fence.db.test.ts`'s own fixture pattern), not the prior
90
+ // free-text `'adr31-test'` literal, which satisfied neither CHECK.
91
+ let workspaceId: string;
39
92
 
40
93
  beforeAll(async () => {
41
94
  db = new Client({ connectionString: process.env.DATABASE_URL });
42
95
  await db.connect();
96
+
97
+ const org = (
98
+ await db.query<{ id: string }>(
99
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
100
+ VALUES ('ADR31 Fence Org', 'adr31-fence-org', now()) RETURNING "id"`,
101
+ )
102
+ ).rows[0]!;
103
+ workspaceId = (
104
+ await db.query<{ id: string }>(
105
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
106
+ VALUES ($1, 'ADR31 Fence Workspace', 'adr31-fence-ws', now()) RETURNING "id"`,
107
+ [org.id],
108
+ )
109
+ ).rows[0]!.id;
43
110
  });
44
111
 
45
112
  afterAll(async () => {
46
- await db.query(`DELETE FROM "leads" WHERE "site_id" = 'adr31-test'`);
113
+ await db.query(`DELETE FROM "leads" WHERE "workspace_id" = $1`, [
114
+ workspaceId,
115
+ ]);
47
116
  await db.query(
48
117
  `DELETE FROM "organizations" WHERE "slug" LIKE 'adr31-%'`,
49
118
  );
@@ -83,8 +152,9 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
83
152
  expect(org.id.slice("org_".length)).toMatch(V7_RE);
84
153
 
85
154
  const lead = (await db.query<{ id: string }>(
86
- `INSERT INTO "leads" ("site_id", "source", "email", "payload")
87
- VALUES ('adr31-test', 'test', 'adr31@example.com', '{}') RETURNING "id"`,
155
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
156
+ VALUES ($1, $1, 'test', 'adr31@example.com', '{}') RETURNING "id"`,
157
+ [workspaceId],
88
158
  )).rows[0];
89
159
  expect(lead.id).toMatch(/^lead_/);
90
160
  expect(lead.id.slice("lead_".length)).toMatch(V7_RE);
@@ -93,10 +163,15 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
93
163
 
94
164
  describe("CHECK constraints — the mechanical tier", () => {
95
165
  it("REJECTS an insert with a malformed primary key", async () => {
166
+ // workspace_id (= site_id) is supplied and well-formed here so the
167
+ // ONLY constraint this insert can violate is leads_id_format — proving
168
+ // the malformed-id rejection still fires for its original reason,
169
+ // not the (now also NOT NULL) workspace_id (#6373 AC-3).
96
170
  await expect(
97
171
  db.query(
98
- `INSERT INTO "leads" ("id", "site_id", "source", "email", "payload")
99
- VALUES ('bad', 'adr31-test', 'x', 'a@b.co', '{}')`,
172
+ `INSERT INTO "leads" ("id", "site_id", "workspace_id", "source", "email", "payload")
173
+ VALUES ('bad', $1, $1, 'x', 'a@b.co', '{}')`,
174
+ [workspaceId],
100
175
  ),
101
176
  ).rejects.toThrow(/leads_id_format/);
102
177
  });
@@ -104,8 +179,9 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
104
179
  it("REJECTS an insert with a bare (unprefixed) uuid primary key", async () => {
105
180
  await expect(
106
181
  db.query(
107
- `INSERT INTO "leads" ("id", "site_id", "source", "email", "payload")
108
- VALUES ('0190f3a2-7c1d-7e4a-9b2c-0123456789ab', 'adr31-test', 'x', 'a@b.co', '{}')`,
182
+ `INSERT INTO "leads" ("id", "site_id", "workspace_id", "source", "email", "payload")
183
+ VALUES ('0190f3a2-7c1d-7e4a-9b2c-0123456789ab', $1, $1, 'x', 'a@b.co', '{}')`,
184
+ [workspaceId],
109
185
  ),
110
186
  ).rejects.toThrow(/leads_id_format/);
111
187
  });
@@ -135,12 +211,12 @@ describeDb("prefixed UUIDv7 ids (ADR-0031, live DB)", () => {
135
211
  expect(ws.id).toBe(DEFAULT_WORKSPACE_ID);
136
212
  });
137
213
 
138
- it("carries the full CHECK complement: 27 PK + 12 scope = 39", async () => {
214
+ it("carries the full CHECK complement declared by the migrations", async () => {
139
215
  const { rows } = await db.query<{ n: string }>(
140
216
  `SELECT count(*)::text AS n FROM pg_constraint
141
217
  WHERE contype = 'c' AND conname LIKE '%_format'`,
142
218
  );
143
- expect(rows[0].n).toBe("39");
219
+ expect(rows[0].n).toBe(String(declaredFormatCheckCount()));
144
220
  });
145
221
  });
146
222
  });