create-ailk 0.5.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 (299) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -5,16 +5,32 @@
5
5
  * POST /v1/leads/submit — lead submission (contact form / inquiry)
6
6
  * POST /v1/leads/subscribe — newsletter subscription
7
7
  * POST /v1/leads/callback — callback request (phone-back)
8
+ *
9
+ * TENANT RESOLUTION (#6203, spec §10.4 operator ruling — reverses this
10
+ * file's former byte-unchanged posture). `siteId` used to be taken VERBATIM
11
+ * from the request body (`body["siteId"] as string`) — an anonymous, no-
12
+ * ownership-check value (`packages/validation/src/schemas/leads.ts`'s
13
+ * `safeSiteIdString` permits a `wksp_<uuidv7>`-shaped literal). Every
14
+ * capture kind now resolves its tenant SERVER-SIDE, via
15
+ * `resolveSiteWorkspaceId()` (`lib/site.ts`) — the SAME alias -> workspace
16
+ * resolution `currentSiteId()`/`AILK_SITE_ID` already drive throughout this
17
+ * app, unconditional on the content adapter — and a client-posted `siteId`
18
+ * is IGNORED entirely: it can no longer place a row anywhere. An unresolved
19
+ * result (no DATABASE_URL, or the alias names no workspace this database
20
+ * holds) is a 503 before any write is attempted — never a guess. See
21
+ * `lead-routing.ts`'s own header for the persistence-side half.
8
22
  */
9
23
 
10
24
  import { track } from "@working-theory/analytics/server";
11
25
  import type { FastifyPluginAsync } from "fastify";
12
26
 
27
+ import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
13
28
  import {
14
29
  type LeadKind,
15
30
  type RouteLeadInput,
16
31
  routeLead,
17
32
  } from "../../services/lead-routing.js";
33
+ import { sendRequestConfirmLink } from "../../services/request-confirm.js";
18
34
 
19
35
  /**
20
36
  * Read a named string field from the body, falling back to the same-named
@@ -42,17 +58,22 @@ function liftString(
42
58
  * `email`/`name`/`phone` out of `payload` (see liftString above) reads by
43
59
  * explicit field name only — it does not spread `payload`'s other keys, so
44
60
  * the same discipline holds. `payload` itself is passed through unchanged.
61
+ *
62
+ * `siteId` is the RESOLVED `workspaceId` (#6203) — never `body["siteId"]`.
63
+ * A body-supplied `siteId` is read nowhere in this function; a client that
64
+ * posts one has it silently ignored, never honoured.
45
65
  */
46
66
  function buildInput(
47
67
  kind: LeadKind,
48
68
  body: Record<string, unknown>,
69
+ workspaceId: string,
49
70
  ): RouteLeadInput {
50
71
  const payload = (body["payload"] ?? {}) as Record<string, unknown>;
51
72
 
52
73
  if (kind === "submit") {
53
74
  return {
54
75
  kind,
55
- siteId: body["siteId"] as string,
76
+ siteId: workspaceId,
56
77
  source: body["source"] as string,
57
78
  email: liftString(body, payload, "email"),
58
79
  payload,
@@ -61,14 +82,14 @@ function buildInput(
61
82
  if (kind === "subscribe") {
62
83
  return {
63
84
  kind,
64
- siteId: body["siteId"] as string,
85
+ siteId: workspaceId,
65
86
  email: liftString(body, payload, "email"),
66
87
  source: body["source"] as string,
67
88
  };
68
89
  }
69
90
  return {
70
91
  kind,
71
- siteId: body["siteId"] as string,
92
+ siteId: workspaceId,
72
93
  name: liftString(body, payload, "name"),
73
94
  phone: liftString(body, payload, "phone"),
74
95
  ...(body["preferredTime"] !== undefined && {
@@ -99,7 +120,46 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
99
120
  receivedAt: new Date().toISOString(),
100
121
  };
101
122
 
102
- const result = await routeLead(buildInput(kind, body), sourceMeta);
123
+ // #6203 — resolve the tenant SERVER-SIDE before any write is
124
+ // attempted. Two branches, deliberately different:
125
+ //
126
+ // - No DATABASE_URL: the zero-config OSS file-fallback deployment.
127
+ // There is no `workspaces` table to resolve against (and
128
+ // `assertSiteAliasResolves()` already skips its own boot check for
129
+ // exactly this reason), so the "resolved" value here is the site
130
+ // ALIAS itself (`currentSiteId()`) — the same value the file
131
+ // fallback would have carried before this unit. Never a rejection:
132
+ // that would break lead capture for every zero-config scaffold.
133
+ // - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
134
+ // SAME alias -> workspace resolution `currentSiteId()` already
135
+ // drives everywhere else in this app. `null` here is a genuine
136
+ // misconfiguration `build()` already refuses to boot past
137
+ // (`assertSiteAliasResolves()`) — reaching it at request time is
138
+ // the residual case that startup check cannot catch (a workspace
139
+ // deleted after boot, a transient read), and it fails closed the
140
+ // same way: reject, never guess (spec AC-4).
141
+ let workspaceId: string;
142
+ if (!process.env.DATABASE_URL) {
143
+ workspaceId = currentSiteId();
144
+ } else {
145
+ const resolved = await resolveSiteWorkspaceId();
146
+ if (resolved === null) {
147
+ request.log.error(
148
+ { kind },
149
+ "lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
150
+ );
151
+ return reply.status(503).send({
152
+ error: "tenant_unresolved",
153
+ message: "This site is not configured to accept submissions.",
154
+ });
155
+ }
156
+ workspaceId = resolved;
157
+ }
158
+
159
+ const result = await routeLead(
160
+ buildInput(kind, body, workspaceId),
161
+ sourceMeta,
162
+ );
103
163
 
104
164
  if (!result.ok) {
105
165
  if (result.error.kind === "invalid_input") {
@@ -110,7 +170,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
110
170
  if (result.error.kind === "persist_failed") {
111
171
  // Do not surface internal error detail — log it server-side only
112
172
  request.log.error(
113
- { kind, siteId: body["siteId"], error: result.error.message },
173
+ { kind, siteId: workspaceId, error: result.error.message },
114
174
  "lead persist_failed",
115
175
  );
116
176
  return reply.status(503).send({ error: "persist_failed" });
@@ -131,7 +191,7 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
131
191
  : "error";
132
192
  request.log.info(
133
193
  {
134
- siteId: body["siteId"],
194
+ siteId: workspaceId,
135
195
  kind,
136
196
  ip: sourceMeta.ip,
137
197
  persisted: result.persisted,
@@ -140,6 +200,35 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
140
200
  "lead routed",
141
201
  );
142
202
 
203
+ // Request-and-confirm (#5970): mint the tenant's one-time confirm
204
+ // credential and email them the link. Fire-and-forget and `void`-
205
+ // returning, so the 201 below stays independent of both Resend and that
206
+ // write — the request is already persisted by this point, and a send
207
+ // that fails must never turn a captured lead into an error.
208
+ //
209
+ // Callback only: `submit` and `subscribe` have no confirm step. The
210
+ // service itself no-ops (with a logged reason) when the feature is not
211
+ // configured, which is the default.
212
+ // `result.persisted === "db"` for the same reason `lead-routing.ts`
213
+ // consults it (#5970 security review round 3, M1): the credential is
214
+ // minted by an update on the `callbacks` row, so a FILE-FALLBACK id
215
+ // would match nothing and the operator would be left with no email at
216
+ // all. On that path the generic notification is sent instead, which is
217
+ // what keeps "exactly one" true in both directions.
218
+ if (kind === "callback" && result.persisted === "db") {
219
+ const callbackInput = buildInput(kind, body, workspaceId) as Extract<
220
+ RouteLeadInput,
221
+ { kind: "callback" }
222
+ >;
223
+ sendRequestConfirmLink(result.id, {
224
+ name: callbackInput.name,
225
+ phone: callbackInput.phone,
226
+ ...(callbackInput.preferredTime !== undefined && {
227
+ preferredTime: callbackInput.preferredTime,
228
+ }),
229
+ });
230
+ }
231
+
143
232
  // Fire-and-forget: track() never throws; no need to hold the response.
144
233
  void track("lead-captured", {
145
234
  source: body["source"] as string | undefined,
@@ -148,13 +237,11 @@ function makeLeadsRoute(kind: LeadKind): FastifyPluginAsync {
148
237
  ] as string | undefined,
149
238
  });
150
239
 
151
- return reply
152
- .status(201)
153
- .send({
154
- id: result.id,
155
- persisted: result.persisted,
156
- email: result.email,
157
- });
240
+ return reply.status(201).send({
241
+ id: result.id,
242
+ persisted: result.persisted,
243
+ email: result.email,
244
+ });
158
245
  });
159
246
  };
160
247
  }
@@ -74,6 +74,10 @@ import {
74
74
  } from "@working-theory/validation";
75
75
 
76
76
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
77
+ import {
78
+ mockNoWorkspace,
79
+ mockWorkspaceExists,
80
+ } from "../../../lib/__mocks__/prisma.js";
77
81
  import { prisma } from "../../../lib/prisma.js";
78
82
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
79
83
  import { startProjectListingRoute } from "../start.js";
@@ -181,6 +185,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
181
185
  }
182
186
 
183
187
  function installStore(): void {
188
+ // #6335 — the shared prisma mock no longer resolves ANY requested
189
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
190
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
191
+ // services/project-listings.ts), so this suite states the ONE site it is
192
+ // built around. Keyed on the requested id — nothing else resolves.
193
+ mockWorkspaceExists(prisma, SITE_ID);
194
+
184
195
  rows = [];
185
196
  nextId = 1;
186
197
  configData = null;
@@ -239,6 +250,7 @@ function installStore(): void {
239
250
  headline: null,
240
251
  logoMarkUrl: null,
241
252
  logoWordmarkUrl: null,
253
+ solution: null,
242
254
  featured: false,
243
255
  decidedAt: null,
244
256
  decidedBy: null,
@@ -573,3 +585,42 @@ describe("the site's own application config (#5048 D5/D6)", () => {
573
585
  });
574
586
  });
575
587
  });
588
+
589
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
590
+ //
591
+ // Every case in this file states that this suite's site has a workspace.
592
+ // This one takes that away, and proves the statement was doing work rather
593
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
594
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
595
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
596
+ // permissive default in place this case could not be written at all — every
597
+ // id resolved, so the fail-closed path was unreachable from a route test.
598
+ describe("#6335 — start fails closed when the site names no workspace", () => {
599
+ let app: FastifyInstance;
600
+
601
+ beforeEach(async () => {
602
+ jest.clearAllMocks();
603
+ installStore();
604
+ mockNoWorkspace(prisma);
605
+ app = await buildApp();
606
+ });
607
+
608
+ afterEach(async () => {
609
+ await app.close();
610
+ });
611
+
612
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
613
+ const res = await app.inject({
614
+ method: "POST",
615
+ url: "/v1/project-listings",
616
+ payload: START_PAYLOAD,
617
+ });
618
+
619
+ expect(res.statusCode).toBe(503);
620
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
621
+ "tenant_unresolved",
622
+ );
623
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
624
+ expect(rows).toHaveLength(0);
625
+ });
626
+ });
@@ -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
+ });