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
@@ -62,11 +62,16 @@ jest.mock("@working-theory/license", () => ({
62
62
  }),
63
63
  }));
64
64
 
65
+ import { logger } from "@working-theory/observability";
65
66
  import {
66
67
  resolveConsentStatement,
67
68
  SITE_CONSENT_CONFIG_DEFAULTS,
68
69
  } from "@working-theory/validation";
69
70
 
71
+ import {
72
+ mockNoWorkspace,
73
+ mockWorkspaceExists,
74
+ } from "../../../lib/__mocks__/prisma.js";
70
75
  import { prisma } from "../../../lib/prisma.js";
71
76
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
72
77
  import {
@@ -118,6 +123,48 @@ const START_PAYLOAD = {
118
123
  consent: true,
119
124
  };
120
125
 
126
+ /**
127
+ * #6314 — the magnets `capturedByFlowSlug` may name. The resolve runs against
128
+ * the RESOLVED workspace id (`resolveWorkspaceIdForSite(SITE_ID)`), which this
129
+ * suite's `mockWorkspaceExists(prisma, SITE_ID)` makes `SITE_ID` itself.
130
+ */
131
+ const WS_OTHER = "wksp_00000000-0000-7000-8000-0000000000bb";
132
+ const LISTICLE_ID = "flow_00000000-0000-7000-8000-0000000000d1";
133
+ const FOREIGN_LISTICLE_ID = "flow_00000000-0000-7000-8000-0000000000d2";
134
+ const CONTACT_FLOW_ID = "flow_00000000-0000-7000-8000-0000000000d3";
135
+
136
+ const FLOW_DEFINITION_ROWS: Row[] = [
137
+ {
138
+ id: LISTICLE_ID,
139
+ siteId: SITE_ID,
140
+ workspaceId: SITE_ID,
141
+ slug: "founder-list",
142
+ kind: "listicle",
143
+ status: "live",
144
+ },
145
+ // A listicle magnet of ANOTHER workspace. Its slug is a public path segment,
146
+ // so a stranger can name it — the resolve's `siteId`/`workspaceId` terms are
147
+ // what make naming it inert.
148
+ {
149
+ id: FOREIGN_LISTICLE_ID,
150
+ siteId: WS_OTHER,
151
+ workspaceId: WS_OTHER,
152
+ slug: "other-list",
153
+ kind: "listicle",
154
+ status: "live",
155
+ },
156
+ // A NON-listicle magnet in THIS workspace: the `kind: "listicle"` term in
157
+ // the resolve is what stops an application being filed against it.
158
+ {
159
+ id: CONTACT_FLOW_ID,
160
+ siteId: SITE_ID,
161
+ workspaceId: SITE_ID,
162
+ slug: "contact-us",
163
+ kind: "contact",
164
+ status: "live",
165
+ },
166
+ ];
167
+
121
168
  /** Everything the ten steps after step one collect. */
122
169
  const COMPLETE_APPLICATION = {
123
170
  name: "My Project",
@@ -162,6 +209,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
162
209
  }
163
210
 
164
211
  function installStore(): void {
212
+ // #6335 — the shared prisma mock no longer resolves ANY requested
213
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
214
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
215
+ // services/project-listings.ts), so this suite states the ONE site it is
216
+ // built around. Keyed on the requested id — nothing else resolves.
217
+ mockWorkspaceExists(prisma, SITE_ID);
218
+
165
219
  rows = [];
166
220
  nextId = 1;
167
221
 
@@ -215,6 +269,7 @@ function installStore(): void {
215
269
  headline: null,
216
270
  logoMarkUrl: null,
217
271
  logoWordmarkUrl: null,
272
+ solution: null,
218
273
  featured: false,
219
274
  decidedAt: null,
220
275
  decidedBy: null,
@@ -261,6 +316,16 @@ function installStore(): void {
261
316
  (prisma.consentGrant.createMany as jest.Mock).mockImplementation(
262
317
  ({ data }: { data: Row[] }) => Promise.resolve({ count: data.length }),
263
318
  );
319
+
320
+ // #6314 — the capturing-magnet resolve. Honours EVERY term the service
321
+ // passes, so a resolve that dropped `workspaceId` or `kind` would match a
322
+ // row it must not and fail the assertions below.
323
+ (prisma.flowDefinition.findFirst as jest.Mock).mockImplementation(
324
+ ({ where }: { where: Record<string, unknown> }) =>
325
+ Promise.resolve(
326
+ FLOW_DEFINITION_ROWS.find((row) => matches(row, where)) ?? null,
327
+ ),
328
+ );
264
329
  }
265
330
 
266
331
  // ─── Helpers ────────────────────────────────────────────────────────────────
@@ -479,6 +544,253 @@ describe("the draft lifecycle (#5036 D1)", () => {
479
544
  expect(data[0]!["statement"]).not.toContain("You agree to being contacted");
480
545
  });
481
546
 
547
+ // ── #6314 — the capturing magnet, written at START ────────────────────────
548
+
549
+ it("AC-5: a capturedByFlowSlug naming a live listicle magnet of the resolved site writes that magnet's ID, and the response body is byte-unchanged", async () => {
550
+ const res = await app.inject({
551
+ method: "POST",
552
+ url: "/v1/project-listings",
553
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
554
+ });
555
+ expect(res.statusCode).toBe(201);
556
+ // The hard-floor response contract does not move: `{ id, resumeToken }`
557
+ // and nothing else. The column is NEVER echoed back to the anonymous
558
+ // caller (spec § 9 SEC-12).
559
+ expect(Object.keys(res.json().data).sort()).toEqual(["id", "resumeToken"]);
560
+
561
+ expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
562
+
563
+ // D1 — ALL FOUR terms in ONE query, and only the slug came from the body.
564
+ const call = (prisma.flowDefinition.findFirst as jest.Mock).mock
565
+ .calls[0]?.[0] as { where: Record<string, unknown> };
566
+ expect(call.where).toEqual({
567
+ slug: "founder-list",
568
+ siteId: SITE_ID,
569
+ workspaceId: SITE_ID,
570
+ kind: "listicle",
571
+ });
572
+ });
573
+
574
+ it("a start body carrying NO capturedByFlowSlug writes NULL, and makes no resolve query at all", async () => {
575
+ await startDraft(app);
576
+ expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
577
+ expect(prisma.flowDefinition.findFirst).not.toHaveBeenCalled();
578
+ });
579
+
580
+ it.each([
581
+ ["names nothing at all", "no-such-magnet"],
582
+ ["names ANOTHER workspace's listicle magnet", "other-list"],
583
+ ["names a NON-listicle magnet in this workspace", "contact-us"],
584
+ ])(
585
+ "AC-6: a capturedByFlowSlug that %s writes NULL and still returns a 201 indistinguishable from a body that named no slug",
586
+ async (_label, slug) => {
587
+ const withSlug = await app.inject({
588
+ method: "POST",
589
+ url: "/v1/project-listings",
590
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: slug },
591
+ });
592
+ expect(withSlug.statusCode).toBe(201);
593
+ expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
594
+
595
+ // Indistinguishable on the wire from the no-slug case: same status, same
596
+ // key set, same envelope. A distinguishable error would turn this
597
+ // anonymous route into a cross-workspace magnet-slug oracle (SEC-02).
598
+ const withoutSlug = await app.inject({
599
+ method: "POST",
600
+ url: "/v1/project-listings",
601
+ payload: START_PAYLOAD,
602
+ });
603
+ expect(withoutSlug.statusCode).toBe(withSlug.statusCode);
604
+ expect(Object.keys(withoutSlug.json().data).sort()).toEqual(
605
+ Object.keys(withSlug.json().data).sort(),
606
+ );
607
+ },
608
+ );
609
+
610
+ it("AC-13: an UNRESOLVED slug is logged, PII-safe — routing metadata only, never the founder's identity or either credential", async () => {
611
+ const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
612
+ try {
613
+ const res = await app.inject({
614
+ method: "POST",
615
+ url: "/v1/project-listings",
616
+ payload: {
617
+ ...START_PAYLOAD,
618
+ capturedByFlowSlug: "no-such-magnet",
619
+ },
620
+ });
621
+ expect(res.statusCode).toBe(201);
622
+
623
+ const call = warn.mock.calls.find(
624
+ (c) =>
625
+ typeof c[0] === "object" &&
626
+ c[0] !== null &&
627
+ "capturedByFlowSlug" in (c[0] as Record<string, unknown>),
628
+ );
629
+ expect(call).toBeDefined();
630
+ const meta = call![0] as Record<string, unknown>;
631
+ // Routing metadata ONLY. Silence here is what would make a stale or
632
+ // mis-wired consuming site produce a permanently empty Applications list
633
+ // indistinguishable from "no applications yet" (SEC-03).
634
+ expect(Object.keys(meta).sort()).toEqual([
635
+ "capturedByFlowSlug",
636
+ "listingId",
637
+ "siteId",
638
+ ]);
639
+ const serialized = JSON.stringify(meta);
640
+ expect(serialized).not.toContain("ada@example.com");
641
+ expect(serialized).not.toContain("Ada Founder");
642
+ expect(serialized).not.toContain(rows[0]!["resumeTokenHash"]);
643
+ expect(serialized).not.toContain(rows[0]!["emailVerifyNonceHash"]);
644
+ } finally {
645
+ warn.mockRestore();
646
+ }
647
+ });
648
+
649
+ it("AC-6: a capturedByFlowSlug lookup that THROWS degrades like an unresolvable slug — 201, NULL attribution, and a PII-safe warn distinct from the unresolved line", async () => {
650
+ (prisma.flowDefinition.findFirst as jest.Mock).mockRejectedValueOnce(
651
+ new Error("statement timeout"),
652
+ );
653
+ const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
654
+ try {
655
+ const res = await app.inject({
656
+ method: "POST",
657
+ url: "/v1/project-listings",
658
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
659
+ });
660
+ expect(res.statusCode).toBe(201);
661
+ expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
662
+
663
+ const call = warn.mock.calls.find(
664
+ (c) => typeof c[1] === "string" && c[1].includes("lookup failed"),
665
+ );
666
+ expect(call).toBeDefined();
667
+ const meta = call![0] as Record<string, unknown>;
668
+ expect(Object.keys(meta).sort()).toEqual([
669
+ "capturedByFlowSlug",
670
+ "err",
671
+ "workspaceId",
672
+ ]);
673
+ // `JSON.stringify` cannot see an Error's NON-ENUMERABLE own properties —
674
+ // `message` and `stack` among them — so stringifying `meta` alone would
675
+ // pass whatever the error held and assert nothing. Pull them out by name.
676
+ const err = meta["err"] as Error;
677
+ const serialized = [
678
+ JSON.stringify(meta),
679
+ err?.message ?? "",
680
+ err?.stack ?? "",
681
+ ].join("\n");
682
+ expect(serialized).not.toContain("ada@example.com");
683
+ expect(serialized).not.toContain("Ada Founder");
684
+
685
+ // The real guarantee is STRUCTURAL, not this assertion: the catch wraps
686
+ // only the `findFirst`, whose scope holds no founder field at all. This
687
+ // check is a tripwire over that property, never the proof of it.
688
+ const line = call![1] as string;
689
+ expect(line).toContain("lookup failed");
690
+ expect(line).not.toContain("resolved to no listicle magnet");
691
+ } finally {
692
+ warn.mockRestore();
693
+ }
694
+ });
695
+
696
+ it("a lookup that THROWS does NOT also emit the unresolved-slug line — during a DB incident that would be a false magnet-configuration signal", async () => {
697
+ (prisma.flowDefinition.findFirst as jest.Mock).mockRejectedValueOnce(
698
+ new Error("statement timeout"),
699
+ );
700
+ const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
701
+ try {
702
+ const res = await app.inject({
703
+ method: "POST",
704
+ url: "/v1/project-listings",
705
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
706
+ });
707
+ expect(res.statusCode).toBe(201);
708
+ expect(rows[0]!["capturedByFlowId"] ?? null).toBeNull();
709
+
710
+ const messages = warn.mock.calls.map((c) => String(c[1] ?? ""));
711
+ // Exactly one line, and it is the accurate one. The throw never
712
+ // established that no such magnet exists.
713
+ expect(
714
+ messages.filter((m) => m.includes("resolved to no listicle magnet")),
715
+ ).toHaveLength(0);
716
+ expect(messages.filter((m) => m.includes("lookup failed"))).toHaveLength(1);
717
+ } finally {
718
+ warn.mockRestore();
719
+ }
720
+ });
721
+
722
+ it("a RESOLVED slug logs nothing — the warn line is the unresolved case's signal, not a per-application line", async () => {
723
+ const warn = jest.spyOn(logger, "warn").mockImplementation(() => undefined);
724
+ try {
725
+ await app.inject({
726
+ method: "POST",
727
+ url: "/v1/project-listings",
728
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
729
+ });
730
+ expect(
731
+ warn.mock.calls.filter(
732
+ (c) =>
733
+ typeof c[0] === "object" &&
734
+ c[0] !== null &&
735
+ "capturedByFlowSlug" in (c[0] as Record<string, unknown>),
736
+ ),
737
+ ).toHaveLength(0);
738
+ } finally {
739
+ warn.mockRestore();
740
+ }
741
+ });
742
+
743
+ it("AC-7: capturedByFlowId is WRITE-ONCE — no patch and no submit changes it, and no patch body can reach it", async () => {
744
+ const { id, resumeToken } = (await (async () => {
745
+ const res = await app.inject({
746
+ method: "POST",
747
+ url: "/v1/project-listings",
748
+ payload: { ...START_PAYLOAD, capturedByFlowSlug: "founder-list" },
749
+ });
750
+ expect(res.statusCode).toBe(201);
751
+ return res.json().data as { id: string; resumeToken: string };
752
+ })());
753
+
754
+ expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
755
+
756
+ // A patch that tries to name the column outright is a `.strict()` 400 —
757
+ // the key is not on any patch schema, so no body can reach the column.
758
+ const injected = await app.inject({
759
+ method: "PATCH",
760
+ url: `/v1/project-listings/drafts/${id}`,
761
+ headers: bearer(resumeToken),
762
+ payload: { capturedByFlowId: FOREIGN_LISTICLE_ID },
763
+ });
764
+ expect(injected.statusCode).toBe(400);
765
+ expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
766
+
767
+ // A legitimate patch leaves it untouched.
768
+ const patched = await app.inject({
769
+ method: "PATCH",
770
+ url: `/v1/project-listings/drafts/${id}`,
771
+ headers: bearer(resumeToken),
772
+ payload: { name: "My Project" },
773
+ });
774
+ expect(patched.statusCode).toBe(200);
775
+ expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
776
+
777
+ // And so does submit.
778
+ await app.inject({
779
+ method: "PATCH",
780
+ url: `/v1/project-listings/drafts/${id}`,
781
+ headers: bearer(resumeToken),
782
+ payload: COMPLETE_APPLICATION,
783
+ });
784
+ const submitted = await app.inject({
785
+ method: "POST",
786
+ url: `/v1/project-listings/drafts/${id}/submit`,
787
+ headers: bearer(resumeToken),
788
+ });
789
+ expect(submitted.statusCode).toBe(200);
790
+ expect(rows[0]!["status"]).toBe("requested");
791
+ expect(rows[0]!["capturedByFlowId"]).toBe(LISTICLE_ID);
792
+ });
793
+
482
794
  // ── patch ──
483
795
 
484
796
  it("patch: a SINGLE-field patch round-trips", async () => {
@@ -884,3 +1196,42 @@ describe("the draft lifecycle (#5036 D1)", () => {
884
1196
  await logging.close();
885
1197
  });
886
1198
  });
1199
+
1200
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
1201
+ //
1202
+ // Every case in this file states that this suite's site has a workspace.
1203
+ // This one takes that away, and proves the statement was doing work rather
1204
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
1205
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
1206
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
1207
+ // permissive default in place this case could not be written at all — every
1208
+ // id resolved, so the fail-closed path was unreachable from a route test.
1209
+ describe("#6335 — start fails closed when the site names no workspace", () => {
1210
+ let app: FastifyInstance;
1211
+
1212
+ beforeEach(async () => {
1213
+ jest.clearAllMocks();
1214
+ installStore();
1215
+ mockNoWorkspace(prisma);
1216
+ app = await buildApp();
1217
+ });
1218
+
1219
+ afterEach(async () => {
1220
+ await app.close();
1221
+ });
1222
+
1223
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
1224
+ const res = await app.inject({
1225
+ method: "POST",
1226
+ url: "/v1/project-listings",
1227
+ payload: START_PAYLOAD,
1228
+ });
1229
+
1230
+ expect(res.statusCode).toBe(503);
1231
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
1232
+ "tenant_unresolved",
1233
+ );
1234
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
1235
+ expect(rows).toHaveLength(0);
1236
+ });
1237
+ });
@@ -56,6 +56,10 @@ import Fastify from "fastify";
56
56
  import type { FastifyInstance } from "fastify";
57
57
 
58
58
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
59
+ import {
60
+ mockNoWorkspace,
61
+ mockWorkspaceExists,
62
+ } from "../../../lib/__mocks__/prisma.js";
59
63
  import { prisma } from "../../../lib/prisma.js";
60
64
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
61
65
  import { startProjectListingRoute } from "../start.js";
@@ -184,6 +188,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
184
188
  }
185
189
 
186
190
  function installStore(): void {
191
+ // #6335 — the shared prisma mock no longer resolves ANY requested
192
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
193
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
194
+ // services/project-listings.ts), so this suite states the ONE site it is
195
+ // built around. Keyed on the requested id — nothing else resolves.
196
+ mockWorkspaceExists(prisma, SITE_ID);
197
+
187
198
  rows = [];
188
199
  nextId = 1;
189
200
  resumeEmailEnabled = true;
@@ -254,6 +265,7 @@ function installStore(): void {
254
265
  headline: null,
255
266
  logoMarkUrl: null,
256
267
  logoWordmarkUrl: null,
268
+ solution: null,
257
269
  featured: false,
258
270
  decidedAt: null,
259
271
  decidedBy: null,
@@ -794,3 +806,42 @@ describe("the resume link's email verification (#5407)", () => {
794
806
  });
795
807
  });
796
808
  });
809
+
810
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
811
+ //
812
+ // Every case in this file states that this suite's site has a workspace.
813
+ // This one takes that away, and proves the statement was doing work rather
814
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
815
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
816
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
817
+ // permissive default in place this case could not be written at all — every
818
+ // id resolved, so the fail-closed path was unreachable from a route test.
819
+ describe("#6335 — start fails closed when the site names no workspace", () => {
820
+ let app: FastifyInstance;
821
+
822
+ beforeEach(async () => {
823
+ jest.clearAllMocks();
824
+ installStore();
825
+ mockNoWorkspace(prisma);
826
+ app = await buildApp();
827
+ });
828
+
829
+ afterEach(async () => {
830
+ await app.close();
831
+ });
832
+
833
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
834
+ const res = await app.inject({
835
+ method: "POST",
836
+ url: "/v1/project-listings",
837
+ payload: START_PAYLOAD,
838
+ });
839
+
840
+ expect(res.statusCode).toBe(503);
841
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
842
+ "tenant_unresolved",
843
+ );
844
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
845
+ expect(rows).toHaveLength(0);
846
+ });
847
+ });
@@ -136,6 +136,7 @@ function row(fixture: ListingFixture) {
136
136
  headline: "Atlas",
137
137
  logoMarkUrl: null,
138
138
  logoWordmarkUrl: null,
139
+ solution: null,
139
140
  featured: false,
140
141
  status: "requested",
141
142
  consentAt: fixture.createdAt,
@@ -419,10 +420,12 @@ describe("GET /v1/project-listings/me — the response shape", () => {
419
420
  expect(byId["prjl_ada_one"]).toBe(7);
420
421
  expect(byId["prjl_ada_two"]).toBe(3);
421
422
  expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
422
- where: { siteId: SITE_A, experimentKey: "atlas-waitlist" },
423
+ // #6258 AC-6 — the fenced read now filters the CONSTRAINED column beside
424
+ // `siteId`; the two are equal by the workspace_site_agree CHECK.
425
+ where: { siteId: SITE_A, workspaceId: SITE_A, experimentKey: "atlas-waitlist" },
423
426
  });
424
427
  expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
425
- where: { siteId: SITE_B, experimentKey: "atlas-two-waitlist" },
428
+ where: { siteId: SITE_B, workspaceId: SITE_B, experimentKey: "atlas-two-waitlist" },
426
429
  });
427
430
  });
428
431
 
@@ -129,6 +129,7 @@ function makeRow(overrides: Partial<Record<string, unknown>>) {
129
129
  headline: null,
130
130
  logoMarkUrl: null,
131
131
  logoWordmarkUrl: null,
132
+ solution: null,
132
133
  featured: false,
133
134
  consentAt: new Date("2026-08-01T00:00:00.000Z"),
134
135
  issueUrl: null,
@@ -413,7 +414,9 @@ describe("GET /v1/project-listings/public", () => {
413
414
  it("counts the waitlist through the listing's own experimentKey, in one hop", async () => {
414
415
  await app.inject({ method: "GET", url: `/v1/project-listings/public?siteKey=${SITE_KEY}` });
415
416
  expect(prisma.waitlistScore.count).toHaveBeenCalledWith({
416
- where: { siteId: SITE, experimentKey: "exp-newest" },
417
+ // #6258 AC-6 — the fenced read now filters the CONSTRAINED column
418
+ // beside `siteId`; the two are equal by the workspace_site_agree CHECK.
419
+ where: { siteId: SITE, workspaceId: SITE, experimentKey: "exp-newest" },
417
420
  });
418
421
  // The id-to-key translation read is gone with #5036 D6d.
419
422
  expect(prisma.waitlistExperiment.findMany).not.toHaveBeenCalled();
@@ -57,6 +57,10 @@ jest.mock("@working-theory/observability", () => ({
57
57
  },
58
58
  }));
59
59
 
60
+ import {
61
+ mockNoWorkspace,
62
+ mockWorkspaceExists,
63
+ } from "../../../lib/__mocks__/prisma.js";
60
64
  import { prisma } from "../../../lib/prisma.js";
61
65
  import { startProjectListingRoute } from "../start.js";
62
66
 
@@ -108,6 +112,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
108
112
  }
109
113
 
110
114
  function installStore(): void {
115
+ // #6335 — the shared prisma mock no longer resolves ANY requested
116
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
117
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
118
+ // services/project-listings.ts), so this suite states the ONE site it is
119
+ // built around. Keyed on the requested id — nothing else resolves.
120
+ mockWorkspaceExists(prisma, SITE_ID);
121
+
111
122
  rows = [];
112
123
  nextId = 1;
113
124
  storedConfig = null;
@@ -516,6 +527,11 @@ describe("the resume email's send (#5406)", () => {
516
527
  let capSite = 0;
517
528
  beforeEach(() => {
518
529
  resolvedSiteId = `site-cap-${++capSite}`;
530
+ // #6335 — this describe files each test against a FRESH site, so the
531
+ // existence statement follows `resolvedSiteId` rather than pinning
532
+ // `SITE_ID`. Still keyed on the requested id: every other one
533
+ // resolves to nothing.
534
+ mockWorkspaceExists(prisma, resolvedSiteId);
519
535
  storedConfig = { resumeEmail: { ...RESUME_EMAIL_ON, maxSendsPerHour: 3 } };
520
536
  });
521
537
 
@@ -600,6 +616,9 @@ describe("the resume email's send (#5406)", () => {
600
616
 
601
617
  // A second site, its own config row, its own empty budget.
602
618
  const OTHER_SITE = "site-2";
619
+ // #6335 — named explicitly, beside this test's own site: the draft is
620
+ // filed against OTHER_SITE, so its workspace must resolve too.
621
+ mockWorkspaceExists(prisma, resolvedSiteId, OTHER_SITE);
603
622
  (prisma.siteListingConfig.findUnique as jest.Mock).mockImplementation(
604
623
  ({ where }: { where: { siteId?: string; publicKey?: string } }) => {
605
624
  if (where.publicKey !== undefined) {
@@ -812,3 +831,42 @@ describe("the resume email's send (#5406)", () => {
812
831
  expect(logged).not.toContain("hello@samhenry.org");
813
832
  });
814
833
  });
834
+
835
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
836
+ //
837
+ // Every case in this file states that this suite's site has a workspace.
838
+ // This one takes that away, and proves the statement was doing work rather
839
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
840
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
841
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
842
+ // permissive default in place this case could not be written at all — every
843
+ // id resolved, so the fail-closed path was unreachable from a route test.
844
+ describe("#6335 — start fails closed when the site names no workspace", () => {
845
+ let app: FastifyInstance;
846
+
847
+ beforeEach(async () => {
848
+ jest.clearAllMocks();
849
+ installStore();
850
+ mockNoWorkspace(prisma);
851
+ app = await buildApp();
852
+ });
853
+
854
+ afterEach(async () => {
855
+ await app.close();
856
+ });
857
+
858
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
859
+ const res = await app.inject({
860
+ method: "POST",
861
+ url: "/v1/project-listings",
862
+ payload: START_PAYLOAD,
863
+ });
864
+
865
+ expect(res.statusCode).toBe(503);
866
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
867
+ "tenant_unresolved",
868
+ );
869
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
870
+ expect(rows).toHaveLength(0);
871
+ });
872
+ });