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,106 @@
1
+ /**
2
+ * @file appointment.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof that `Appointment.noShow` correctly targets the
5
+ * `no_show` column (#6162). Requires DATABASE_URL pointing at a Postgres with
6
+ * the migrations applied (`prisma migrate deploy`) — the whole suite SKIPS
7
+ * when DATABASE_URL is unset, keeping the workspace Jest run DB-free (the
8
+ * repo convention — see `prefixed-ids.db.test.ts`; the `apps/api Route Tests
9
+ * (Postgres)` workflow runs this file with a real DB, per its own explicit
10
+ * `pnpm --filter @working-theory/database test` step's precedent).
11
+ *
12
+ * The bug this guards: `Appointment.noShow` carried no `@map("no_show")` —
13
+ * every sibling field on the model does — while the migration that creates
14
+ * the table (`20260918120000_add_appointment`) names the column `no_show`.
15
+ * Prisma's generated client then targets the literal column `noShow`, which
16
+ * does not exist, and every `prisma.appointment.create()` (or any other
17
+ * write/read touching this field) fails at runtime.
18
+ *
19
+ * Spawns `appointment-create-probe.ts` via `tsx` (a real ESM process) rather
20
+ * than importing the generated client directly in this file — the generated
21
+ * client (`generated/client/client.ts`) uses `import.meta.url` and cannot
22
+ * load under this package's CJS `ts-jest` transform (the same constraint
23
+ * `prefixed-ids.db.test.ts` documents; see that file's header for why THAT
24
+ * test uses raw `pg` instead — this one needs the real Prisma runtime
25
+ * specifically to prove the `@map` targets the right column, which a raw-SQL
26
+ * probe cannot exercise since it never goes through Prisma's own column-name
27
+ * resolution).
28
+ *
29
+ * RED, reproduced by hand against this exact fixture before the fix (`git
30
+ * stash` the `@map("no_show")` line, `prisma generate`, re-run):
31
+ *
32
+ * PrismaClientKnownRequestError: The column `noShow of relation
33
+ * appointments` does not exist in the current database.
34
+ *
35
+ * GREEN, after `@map("no_show")` is restored: the probe's stdout is the
36
+ * created row, `noShow: true` included, and the process exits 0. Both runs
37
+ * are logged in this PR's Verification section.
38
+ */
39
+ import { spawnSync } from "node:child_process";
40
+ import { existsSync } from "node:fs";
41
+ import { join } from "node:path";
42
+
43
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
44
+
45
+ const DATABASE_DIR = join(__dirname, "..");
46
+ const PROBE_SCRIPT = join(__dirname, "fixtures", "appointment-create-probe.ts");
47
+
48
+ /** Walk up from DATABASE_DIR to find node_modules/.bin/tsx (worktree-safe —
49
+ * mirrors `database/inbox/__tests__/schema.test.ts`'s own tsx-via-pnpm-exec
50
+ * invocation, but resolves the binary directly since this call needs a
51
+ * scoped `env` — including a possibly-overridden DATABASE_URL — that `pnpm
52
+ * exec` would otherwise have to re-derive). */
53
+ function findTsxBin(): string {
54
+ let dir = DATABASE_DIR;
55
+ for (let i = 0; i < 5; i++) {
56
+ const candidate = join(dir, "node_modules", ".bin", "tsx");
57
+ if (existsSync(candidate)) return candidate;
58
+ dir = join(dir, "..");
59
+ }
60
+ throw new Error(`tsx binary not found searching up from ${DATABASE_DIR}.`);
61
+ }
62
+
63
+ describeDb(
64
+ "Appointment.noShow — live DB round trip via prisma.appointment.create() (#6162)",
65
+ () => {
66
+ it("creates and reads back an Appointment row, including noShow", () => {
67
+ const tsxBin = findTsxBin();
68
+ const result = spawnSync(tsxBin, [PROBE_SCRIPT], {
69
+ cwd: DATABASE_DIR,
70
+ encoding: "utf-8",
71
+ env: process.env,
72
+ });
73
+
74
+ // Asserting on exit status + a successful parse of stdout — rather
75
+ // than requiring empty stderr — is the full signal: a stray Node/tsx
76
+ // diagnostic (an experimental-feature banner, a deprecation warning)
77
+ // on an otherwise-successful run must not fail this test (code review
78
+ // on #6162). A genuine failure still surfaces loudly: `status` carries
79
+ // the exit code and `stderr` the probe's own logged message.
80
+ if (result.status !== 0) {
81
+ throw new Error(
82
+ `probe exited ${result.status}: ${result.stderr || "(no stderr)"}`,
83
+ );
84
+ }
85
+
86
+ const row = JSON.parse(result.stdout) as {
87
+ siteId: string;
88
+ workspaceId: string;
89
+ attendeeEmail: string;
90
+ status: string;
91
+ noShow: boolean;
92
+ noShowEventAt: string | null;
93
+ };
94
+ expect(row.siteId).toBe("wksp_00000000-0000-7000-8000-00000000f1f1");
95
+ // #6374 — the probe row is fenced like every other appointments row:
96
+ // `workspace_id` is NOT NULL and must EQUAL `site_id`. A probe that
97
+ // still wrote the pre-fence free-form site id would be rejected by
98
+ // `appointments_workspace_id_format` before this assertion ran.
99
+ expect(row.workspaceId).toBe(row.siteId);
100
+ expect(row.attendeeEmail).toBe("appointment-probe@example.invalid");
101
+ expect(row.status).toBe("confirmed");
102
+ expect(row.noShow).toBe(true);
103
+ expect(row.noShowEventAt).not.toBeNull();
104
+ });
105
+ },
106
+ );
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @file artifact-content-store.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of roadmap D1/D7 (#5800) — the generalized `Artifact` and
5
+ * the workspace-scoped `Asset`. Requires DATABASE_URL pointing at a Postgres
6
+ * with the migrations applied (`prisma migrate deploy`); the whole suite SKIPS
7
+ * when DATABASE_URL is unset, matching `prefixed-ids.db.test.ts` and keeping the
8
+ * workspace Jest run DB-free.
9
+ *
10
+ * Raw SQL through `pg` for the same reason that file gives: the generated Prisma
11
+ * client is ESM-only and cannot load under this package's CJS ts-jest transform,
12
+ * and raw SQL is the sharper proof anyway — a constraint must hold on the path
13
+ * no application helper can guard.
14
+ *
15
+ * What it proves:
16
+ * 1. THE SURVIVAL PROPERTY (D1). An artifact produced inside an agent session
17
+ * outlives that session: deleting the session sets `session_id` to NULL and
18
+ * leaves the row, its versions, and its publish pointer intact. Before this
19
+ * migration the FK was `ON DELETE CASCADE` on a NOT NULL column, so the
20
+ * same delete destroyed the workspace's content. This is the whole point of
21
+ * "the session becomes provenance, the workspace owns the artifact".
22
+ * 2. The identity of a published page — `(workspace_id, content_type, locale,
23
+ * slug)` is unique, while unpublished drafts (slug NULL) are unconstrained,
24
+ * because NULLs never collide in a Postgres unique index.
25
+ * 3. `published_version_id` is an independent pointer: it is NOT `is_current`,
26
+ * and appending a newer draft tip does not move it.
27
+ * 4. `assets` is workspace-scoped with a unique `(workspace_id, key)` and a
28
+ * DB-minted `asset_` UUIDv7 id (ADR-0031).
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+
32
+ import { Client } from "pg";
33
+
34
+ import { DEFAULT_WORKSPACE_ID } from "../tenancy.js";
35
+
36
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
37
+
38
+ const V7_RE =
39
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
40
+
41
+ /** A run-unique suffix so repeated local runs never collide on the unique key. */
42
+ const RUN = randomUUID().slice(0, 8);
43
+
44
+ describeDb("Artifact content store (roadmap D1/D7, live DB)", () => {
45
+ let db: Client;
46
+
47
+ beforeAll(async () => {
48
+ db = new Client({ connectionString: process.env.DATABASE_URL });
49
+ await db.connect();
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await db.query(
54
+ `DELETE FROM "artifacts" WHERE "title" LIKE $1`,
55
+ [`5800-${RUN}%`],
56
+ );
57
+ await db.query(`DELETE FROM "agent_sessions" WHERE "title" LIKE $1`, [
58
+ `5800-${RUN}%`,
59
+ ]);
60
+ await db.query(`DELETE FROM "assets" WHERE "key" LIKE $1`, [
61
+ `5800-${RUN}%`,
62
+ ]);
63
+ await db.end();
64
+ });
65
+
66
+ describe("the session is provenance — deleting it never destroys content", () => {
67
+ it("sets session_id NULL and leaves the artifact, its versions and its publish pointer", async () => {
68
+ const sessionId = (
69
+ await db.query<{ id: string }>(
70
+ `INSERT INTO "agent_sessions" ("workspace_id", "title", "updated_at")
71
+ VALUES ($1, $2, now()) RETURNING "id"`,
72
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} session`],
73
+ )
74
+ ).rows[0]!.id;
75
+
76
+ const artifactId = (
77
+ await db.query<{ id: string }>(
78
+ `INSERT INTO "artifacts" ("session_id", "workspace_id", "title", "content_type", "locale", "slug", "updated_at")
79
+ VALUES ($1, $2, $3, 'text/page', 'en', $4, now()) RETURNING "id"`,
80
+ [
81
+ sessionId,
82
+ DEFAULT_WORKSPACE_ID,
83
+ `5800-${RUN} page`,
84
+ `5800-${RUN}-survivor`,
85
+ ],
86
+ )
87
+ ).rows[0]!.id;
88
+
89
+ const versionId = (
90
+ await db.query<{ id: string }>(
91
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
92
+ VALUES ($1, $2, '{"body":"v1"}'::jsonb, TRUE) RETURNING "id"`,
93
+ [artifactId, DEFAULT_WORKSPACE_ID],
94
+ )
95
+ ).rows[0]!.id;
96
+
97
+ await db.query(
98
+ `UPDATE "artifacts" SET "published_version_id" = $1 WHERE "id" = $2`,
99
+ [versionId, artifactId],
100
+ );
101
+
102
+ // The act under test: the producing session is deleted outright.
103
+ await db.query(`DELETE FROM "agent_sessions" WHERE "id" = $1`, [
104
+ sessionId,
105
+ ]);
106
+
107
+ const { rows } = await db.query<{
108
+ session_id: string | null;
109
+ workspace_id: string;
110
+ published_version_id: string | null;
111
+ }>(
112
+ `SELECT "session_id", "workspace_id", "published_version_id"
113
+ FROM "artifacts" WHERE "id" = $1`,
114
+ [artifactId],
115
+ );
116
+
117
+ expect(rows).toHaveLength(1);
118
+ expect(rows[0]!.session_id).toBeNull();
119
+ expect(rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
120
+ expect(rows[0]!.published_version_id).toBe(versionId);
121
+
122
+ const versions = await db.query(
123
+ `SELECT "id" FROM "artifact_versions" WHERE "artifact_id" = $1`,
124
+ [artifactId],
125
+ );
126
+ expect(versions.rows).toHaveLength(1);
127
+ });
128
+
129
+ it("accepts an artifact with no session at all — the workspace owns it", async () => {
130
+ const { rows } = await db.query<{ id: string; session_id: string | null }>(
131
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
132
+ VALUES ($1, $2, 'text/page', 'en', now()) RETURNING "id", "session_id"`,
133
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} sessionless`],
134
+ );
135
+
136
+ expect(rows[0]!.session_id).toBeNull();
137
+ expect(rows[0]!.id).toMatch(/^artif_/);
138
+ });
139
+ });
140
+
141
+ describe("published identity", () => {
142
+ it("rejects a second artifact with the same (workspace, contentType, locale, slug)", async () => {
143
+ const slug = `5800-${RUN}-dupe`;
144
+ await db.query(
145
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
146
+ VALUES ($1, $2, 'text/page', 'en', $3, now())`,
147
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} first`, slug],
148
+ );
149
+
150
+ await expect(
151
+ db.query(
152
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
153
+ VALUES ($1, $2, 'text/page', 'en', $3, now())`,
154
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} second`, slug],
155
+ ),
156
+ ).rejects.toThrow(/duplicate key|unique/i);
157
+ });
158
+
159
+ it("admits many unpublished drafts — a NULL slug never collides", async () => {
160
+ for (const n of [1, 2, 3]) {
161
+ await db.query(
162
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
163
+ VALUES ($1, $2, 'text/page', 'en', now())`,
164
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} draft ${n}`],
165
+ );
166
+ }
167
+
168
+ const { rows } = await db.query<{ count: string }>(
169
+ `SELECT count(*)::text AS count FROM "artifacts"
170
+ WHERE "title" LIKE $1 AND "slug" IS NULL`,
171
+ [`5800-${RUN} draft %`],
172
+ );
173
+ expect(rows[0]!.count).toBe("3");
174
+ });
175
+
176
+ it("separates the same slug across locales", async () => {
177
+ const slug = `5800-${RUN}-locale`;
178
+ for (const locale of ["en", "fr"]) {
179
+ await db.query(
180
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
181
+ VALUES ($1, $2, 'text/page', $3, $4, now())`,
182
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} ${locale}`, locale, slug],
183
+ );
184
+ }
185
+
186
+ const { rows } = await db.query<{ count: string }>(
187
+ `SELECT count(*)::text AS count FROM "artifacts" WHERE "slug" = $1`,
188
+ [slug],
189
+ );
190
+ expect(rows[0]!.count).toBe("2");
191
+ });
192
+ });
193
+
194
+ describe("the publish pointer is not the draft tip", () => {
195
+ it("survives a newer is_current version", async () => {
196
+ const artifactId = (
197
+ await db.query<{ id: string }>(
198
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
199
+ VALUES ($1, $2, 'text/page', 'en', now()) RETURNING "id"`,
200
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} pointer`],
201
+ )
202
+ ).rows[0]!.id;
203
+
204
+ const published = (
205
+ await db.query<{ id: string }>(
206
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
207
+ VALUES ($1, $2, '{"body":"published"}'::jsonb, TRUE) RETURNING "id"`,
208
+ [artifactId, DEFAULT_WORKSPACE_ID],
209
+ )
210
+ ).rows[0]!.id;
211
+
212
+ await db.query(
213
+ `UPDATE "artifacts" SET "published_version_id" = $1 WHERE "id" = $2`,
214
+ [published, artifactId],
215
+ );
216
+
217
+ // A newer draft tip lands; the publish pointer must NOT follow it.
218
+ await db.query(
219
+ `UPDATE "artifact_versions" SET "is_current" = FALSE WHERE "artifact_id" = $1`,
220
+ [artifactId],
221
+ );
222
+ const draft = (
223
+ await db.query<{ id: string }>(
224
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
225
+ VALUES ($1, $2, '{"body":"draft"}'::jsonb, TRUE) RETURNING "id"`,
226
+ [artifactId, DEFAULT_WORKSPACE_ID],
227
+ )
228
+ ).rows[0]!.id;
229
+
230
+ const { rows } = await db.query<{ published_version_id: string | null }>(
231
+ `SELECT "published_version_id" FROM "artifacts" WHERE "id" = $1`,
232
+ [artifactId],
233
+ );
234
+ expect(rows[0]!.published_version_id).toBe(published);
235
+ expect(rows[0]!.published_version_id).not.toBe(draft);
236
+ });
237
+ });
238
+
239
+ describe("Asset (D7)", () => {
240
+ it("mints an asset_ UUIDv7 id and stores the bytes", async () => {
241
+ const { rows } = await db.query<{ id: string; size: number }>(
242
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
243
+ VALUES ($1, $2, 'logo.png', 'image/png', $3, 4) RETURNING "id", "size"`,
244
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN}-logo`, Buffer.from([1, 2, 3, 4])],
245
+ );
246
+
247
+ expect(rows[0]!.id).toMatch(/^asset_/);
248
+ expect(rows[0]!.id.slice("asset_".length)).toMatch(V7_RE);
249
+ expect(rows[0]!.size).toBe(4);
250
+ });
251
+
252
+ it("rejects a second asset with the same (workspace, key)", async () => {
253
+ const key = `5800-${RUN}-dupe-key`;
254
+ await db.query(
255
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
256
+ VALUES ($1, $2, 'a.png', 'image/png', $3, 1)`,
257
+ [DEFAULT_WORKSPACE_ID, key, Buffer.from([1])],
258
+ );
259
+
260
+ await expect(
261
+ db.query(
262
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
263
+ VALUES ($1, $2, 'b.png', 'image/png', $3, 1)`,
264
+ [DEFAULT_WORKSPACE_ID, key, Buffer.from([2])],
265
+ ),
266
+ ).rejects.toThrow(/duplicate key|unique/i);
267
+ });
268
+
269
+ it("rejects a malformed workspace_id (ADR-0031 CHECK)", async () => {
270
+ await expect(
271
+ db.query(
272
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
273
+ VALUES ('not-a-workspace-id', $1, 'a.png', 'image/png', $2, 1)`,
274
+ [`5800-${RUN}-bad-scope`, Buffer.from([1])],
275
+ ),
276
+ ).rejects.toThrow(/assets_workspace_id_format|check constraint/i);
277
+ });
278
+
279
+ // #6092 (S4/D10) — title, description and tags. `tags` stores SLUGS, never
280
+ // the human labels the lightbox renders (D3): 'ad-creative' and
281
+ // 'user-uploaded' are the exact pair the demo's first asset carries
282
+ // (demo.html:357).
283
+ it("stores and returns two tags", async () => {
284
+ const { rows } = await db.query<{ tags: string[] }>(
285
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size", "tags")
286
+ VALUES ($1, $2, 'tagged.png', 'image/png', $3, 1, $4) RETURNING "tags"`,
287
+ [
288
+ DEFAULT_WORKSPACE_ID,
289
+ `5800-${RUN}-tagged`,
290
+ Buffer.from([1]),
291
+ ["ad-creative", "user-uploaded"],
292
+ ],
293
+ );
294
+
295
+ expect(rows[0]!.tags).toEqual(["ad-creative", "user-uploaded"]);
296
+ expect(rows[0]!.tags).toHaveLength(2);
297
+ });
298
+
299
+ it("defaults title, description and tags when none of the three columns is named", async () => {
300
+ const { rows } = await db.query<{
301
+ title: string | null;
302
+ description: string | null;
303
+ tags: string[];
304
+ }>(
305
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
306
+ VALUES ($1, $2, 'untagged.png', 'image/png', $3, 1)
307
+ RETURNING "title", "description", "tags"`,
308
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN}-untagged`, Buffer.from([1])],
309
+ );
310
+
311
+ expect(rows[0]!.title).toBeNull();
312
+ expect(rows[0]!.description).toBeNull();
313
+ expect(rows[0]!.tags).toEqual([]);
314
+ });
315
+ });
316
+ });
@@ -0,0 +1,206 @@
1
+ /**
2
+ * @file composite-fence.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the #6254 keystone constraints (AC-2, AC-5, AC-7).
5
+ * Requires DATABASE_URL pointing at a Postgres with the migrations applied
6
+ * (`prisma migrate deploy`); the whole suite SKIPS when DATABASE_URL is
7
+ * unset, matching `flow-workspace-key.db.test.ts` / `prefixed-ids.db.test.ts`
8
+ * and keeping the workspace Jest run DB-free.
9
+ *
10
+ * Raw SQL through `pg`, for the same reason those files give: the generated
11
+ * Prisma client is ESM-only and cannot load under this package's CJS ts-jest
12
+ * transform.
13
+ *
14
+ * This suite proves the CONSTRAINTS database/migrations/
15
+ * 20260922120000_tenancy_fence adds — it does not re-prove that migration's
16
+ * own backfill (that is `flow-workspace-key.db.test.ts`'s job for its two
17
+ * models, and this migration's backfill is largely a defensive re-run of
18
+ * work the #6155/#6203 precedent migrations already did — see this
19
+ * migration's own header comment). What it proves (spec § 8.2):
20
+ *
21
+ * 1. `Lead` rejects a row whose `workspaceId` disagrees with its `siteId`
22
+ * — SQLSTATE 23514 (AC-5's core).
23
+ * 2. `Lead` accepts a row whose `workspaceId` AGREES with its `siteId` —
24
+ * without this, assertion 1 would also pass against a table that
25
+ * rejects everything.
26
+ * 3. `FlowDefinition` rejects the same disagreement — AC-5's "at least
27
+ * one retrofit model besides Lead".
28
+ * 4. NULL is unstorable on `Lead` and `FlowDefinition` — SQLSTATE 23502
29
+ * (not_null_violation) — AC-7.
30
+ * 5. `WorkspaceSiteIdentity` carries the AC-2 composite unique constraint,
31
+ * proven at the database, not just in the schema file.
32
+ * 6. The migration's own literal sentinel id equals `DEFAULT_WORKSPACE_ID`
33
+ * — the two cannot drift apart (the #6155/#6203 precedents' own
34
+ * technique).
35
+ */
36
+ import { randomUUID } from "node:crypto";
37
+ import { readFileSync } from "node:fs";
38
+ import { join } from "node:path";
39
+
40
+ import { Client } from "pg";
41
+
42
+ import { DEFAULT_WORKSPACE_ID } from "../tenancy.js";
43
+
44
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
45
+
46
+ /** A run-unique suffix so repeated local runs never collide. */
47
+ const RUN = randomUUID().slice(0, 8);
48
+
49
+ const MIGRATION_SQL_PATH = join(
50
+ __dirname,
51
+ "..",
52
+ "migrations",
53
+ "20260922120000_tenancy_fence",
54
+ "migration.sql",
55
+ );
56
+ const MIGRATION_SQL = readFileSync(MIGRATION_SQL_PATH, "utf-8");
57
+
58
+ /** Postgres's own error shape carries `code` as a 5-character SQLSTATE. */
59
+ type PgError = { code?: string };
60
+
61
+ describeDb("the #6254 tenancy-fence constraints (live DB)", () => {
62
+ let db: Client;
63
+ let workspaceAId: string;
64
+ let workspaceBId: string;
65
+
66
+ beforeAll(async () => {
67
+ db = new Client({ connectionString: process.env.DATABASE_URL });
68
+ await db.connect();
69
+
70
+ // Two REAL, distinct workspaces — A and B — so a "disagreement" insert
71
+ // names two values that both actually exist, not a real one versus
72
+ // garbage (which the format CHECK, not the equality CHECK, would catch).
73
+ const org = (
74
+ await db.query<{ id: string }>(
75
+ `INSERT INTO "organizations" ("name", "slug", "updated_at")
76
+ VALUES ($1, $2, now()) RETURNING "id"`,
77
+ [`6254 Org ${RUN}`, `6254-org-${RUN}`],
78
+ )
79
+ ).rows[0]!;
80
+
81
+ workspaceAId = (
82
+ await db.query<{ id: string }>(
83
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
84
+ VALUES ($1, $2, $3, now()) RETURNING "id"`,
85
+ [org.id, `6254 Workspace A ${RUN}`, `6254-ws-a-${RUN}`],
86
+ )
87
+ ).rows[0]!.id;
88
+
89
+ workspaceBId = (
90
+ await db.query<{ id: string }>(
91
+ `INSERT INTO "workspaces" ("organization_id", "name", "slug", "updated_at")
92
+ VALUES ($1, $2, $3, now()) RETURNING "id"`,
93
+ [org.id, `6254 Workspace B ${RUN}`, `6254-ws-b-${RUN}`],
94
+ )
95
+ ).rows[0]!.id;
96
+ });
97
+
98
+ afterAll(async () => {
99
+ await db.query(`DELETE FROM "flow_definitions" WHERE "slug" LIKE $1`, [
100
+ `6254-${RUN}-%`,
101
+ ]);
102
+ await db.query(`DELETE FROM "leads" WHERE "source" = $1`, [
103
+ `6254-test-${RUN}`,
104
+ ]);
105
+ await db.query(`DELETE FROM "workspaces" WHERE "slug" LIKE $1`, [
106
+ `6254-ws-%-${RUN}`,
107
+ ]);
108
+ await db.query(`DELETE FROM "organizations" WHERE "slug" = $1`, [
109
+ `6254-org-${RUN}`,
110
+ ]);
111
+ await db.end();
112
+ });
113
+
114
+ describe("AC-5 — the equality CHECK rejects a site<->workspace disagreement", () => {
115
+ it("Lead: workspace_id disagreeing with site_id raises 23514", async () => {
116
+ await expect(
117
+ db.query(
118
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
119
+ VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
120
+ [workspaceAId, workspaceBId, `6254-test-${RUN}`, "disagree@example.com"],
121
+ ),
122
+ ).rejects.toMatchObject<{ code: PgError["code"] }>({ code: "23514" });
123
+ });
124
+
125
+ it("Lead: workspace_id AGREEING with site_id succeeds", async () => {
126
+ const result = await db.query<{ id: string }>(
127
+ `INSERT INTO "leads" ("site_id", "workspace_id", "source", "email", "payload")
128
+ VALUES ($1, $1, $2, $3, '{}'::jsonb) RETURNING "id"`,
129
+ [workspaceAId, `6254-test-${RUN}`, "agree@example.com"],
130
+ );
131
+ expect(result.rows).toHaveLength(1);
132
+ });
133
+
134
+ it("FlowDefinition (the second retrofit model, AC-5's own requirement): workspace_id disagreeing with site_id raises 23514", async () => {
135
+ await expect(
136
+ db.query(
137
+ `INSERT INTO "flow_definitions"
138
+ ("site_id", "workspace_id", "slug", "config", "title", "status", "updated_at")
139
+ VALUES ($1, $2, $3, '{}'::jsonb, 'Test', 'live', now())`,
140
+ [workspaceAId, workspaceBId, `6254-${RUN}-disagree`],
141
+ ),
142
+ ).rejects.toMatchObject<{ code: PgError["code"] }>({ code: "23514" });
143
+ });
144
+
145
+ it("FlowDefinition: workspace_id AGREEING with site_id succeeds", async () => {
146
+ const result = await db.query<{ id: string }>(
147
+ `INSERT INTO "flow_definitions"
148
+ ("site_id", "workspace_id", "slug", "config", "title", "status", "updated_at")
149
+ VALUES ($1, $1, $2, '{}'::jsonb, 'Test', 'live', now()) RETURNING "id"`,
150
+ [workspaceAId, `6254-${RUN}-agree`],
151
+ );
152
+ expect(result.rows).toHaveLength(1);
153
+ });
154
+ });
155
+
156
+ describe("AC-7 — NOT NULL: no row can exist with workspace_id IS NULL", () => {
157
+ it("Lead: omitting workspace_id raises 23502 (not_null_violation)", async () => {
158
+ await expect(
159
+ db.query(
160
+ `INSERT INTO "leads" ("site_id", "source", "email", "payload")
161
+ VALUES ($1, $2, $3, '{}'::jsonb)`,
162
+ [workspaceAId, `6254-test-${RUN}`, "null@example.com"],
163
+ ),
164
+ ).rejects.toMatchObject<{ code: PgError["code"] }>({ code: "23502" });
165
+ });
166
+
167
+ it("FlowDefinition: omitting workspace_id raises 23502 (not_null_violation)", async () => {
168
+ await expect(
169
+ db.query(
170
+ `INSERT INTO "flow_definitions" ("site_id", "slug", "config", "title", "status", "updated_at")
171
+ VALUES ($1, $2, '{}'::jsonb, 'Test', 'live', now())`,
172
+ [workspaceAId, `6254-${RUN}-null`],
173
+ ),
174
+ ).rejects.toMatchObject<{ code: PgError["code"] }>({ code: "23502" });
175
+ });
176
+ });
177
+
178
+ describe("AC-2 — WorkspaceSiteIdentity's composite unique exists at the database", () => {
179
+ it("pg_constraint reports a UNIQUE constraint over (id, workspace_id)", async () => {
180
+ const result = await db.query<{ contype: string; conkey_count: number }>(
181
+ `SELECT c.contype, array_length(c.conkey, 1) AS conkey_count
182
+ FROM pg_constraint c
183
+ JOIN pg_class t ON t.oid = c.conrelid
184
+ WHERE t.relname = 'workspace_site_identities'
185
+ AND c.conname = 'workspace_site_identities_id_workspace_id_key'`,
186
+ );
187
+ expect(result.rows).toHaveLength(1);
188
+ expect(result.rows[0]!.contype).toBe("u");
189
+ expect(result.rows[0]!.conkey_count).toBe(2);
190
+ });
191
+ });
192
+
193
+ describe("the migration's own literal sentinel id equals DEFAULT_WORKSPACE_ID", () => {
194
+ it("cannot drift apart — read from the shipped file, not a re-typed copy", () => {
195
+ // SQL cannot import database/tenancy.ts (spec § 5.3 step 5), so the
196
+ // migration carries the sentinel as a literal. Pin it against the
197
+ // exported constant by reading the shipped file, exactly as the
198
+ // #6155/#6203 precedents do.
199
+ const occurrences = MIGRATION_SQL.match(/'wksp_[0-9a-f-]+'/g) ?? [];
200
+ expect(occurrences.length).toBeGreaterThan(0);
201
+ for (const literal of occurrences) {
202
+ expect(literal).toBe(`'${DEFAULT_WORKSPACE_ID}'`);
203
+ }
204
+ });
205
+ });
206
+ });