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
@@ -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
+ });
@@ -57,6 +57,10 @@ import type { FastifyInstance } from "fastify";
57
57
  import { SITE_LISTING_CONFIG_DEFAULTS } from "@working-theory/validation";
58
58
 
59
59
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
60
+ import {
61
+ mockNoWorkspace,
62
+ mockWorkspaceExists,
63
+ } from "../../../lib/__mocks__/prisma.js";
60
64
  import { prisma } from "../../../lib/prisma.js";
61
65
  import { listProjectListings } from "../../../services/project-listings.js";
62
66
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
@@ -137,6 +141,8 @@ const COLLECTED_WITH_OPT_IN = [
137
141
  "logoMarkUrl",
138
142
  "logoWordmarkUrl",
139
143
  "requestedWindowDays",
144
+ // #5096 — the fourth. Same opt-in treatment for the same reason.
145
+ "solution",
140
146
  ];
141
147
 
142
148
  const COLLECTED_BEFORE_5285 = [
@@ -198,6 +204,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
198
204
  }
199
205
 
200
206
  function installStore(): void {
207
+ // #6335 — the shared prisma mock no longer resolves ANY requested
208
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
209
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
210
+ // services/project-listings.ts), so this suite states the ONE site it is
211
+ // built around. Keyed on the requested id — nothing else resolves.
212
+ mockWorkspaceExists(prisma, SITE_ID);
213
+
201
214
  rows = [];
202
215
  nextId = 1;
203
216
  configData = null;
@@ -258,6 +271,7 @@ function installStore(): void {
258
271
  headline: null,
259
272
  logoMarkUrl: null,
260
273
  logoWordmarkUrl: null,
274
+ solution: null,
261
275
  featured: false,
262
276
  decidedAt: null,
263
277
  decidedBy: null,
@@ -552,9 +566,9 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
552
566
  });
553
567
  });
554
568
 
555
- // ── The three contract fields ──
569
+ // ── The four contract fields ──
556
570
 
557
- describe("the three contract fields that became writable", () => {
571
+ describe("the four contract fields that became writable", () => {
558
572
  it("patch: the two logo urls round-trip onto the row, on a site that asked", async () => {
559
573
  configData = { collectedFields: COLLECTED_WITH_OPT_IN };
560
574
  const draft = await startDraft(app);
@@ -569,7 +583,7 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
569
583
  expect(rows[0]!["logoWordmarkUrl"]).toBe("https://cdn.example.com/wordmark.svg");
570
584
  });
571
585
 
572
- it("patch: REFUSES all three on a site that never opted in, writing nothing", async () => {
586
+ it("patch: REFUSES all four on a site that never opted in, writing nothing", async () => {
573
587
  // The defect the back-compat review found, at the route. `collectedFields`
574
588
  // defaults to every field the payload defines, so adding these three
575
589
  // silently made them writable on every site that had configured nothing —
@@ -583,12 +597,14 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
583
597
  { logoMarkUrl: "https://evil.example/a.png" },
584
598
  { logoWordmarkUrl: "https://evil.example/b.svg" },
585
599
  { requestedWindowDays: 9000 },
600
+ { solution: "Text a site never asked for." },
586
601
  ]) {
587
602
  expect((await patch(app, draft, body)).statusCode).toBe(400);
588
603
  }
589
604
 
590
605
  expect(rows[0]!["logoMarkUrl"]).toBeNull();
591
606
  expect(rows[0]!["logoWordmarkUrl"]).toBeNull();
607
+ expect(rows[0]!["solution"]).toBeNull();
592
608
  });
593
609
 
594
610
  it("patch: refuses a logo url that is not http(s) — they render as image sources", async () => {
@@ -650,15 +666,20 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
650
666
 
651
667
  // ── The floor the three new fields must not move ──
652
668
 
653
- describe("submit requirements are unchanged by the three added fields", () => {
654
- it("the three are OPT-IN, absent from the default collected set, and never required at submit", async () => {
669
+ describe("submit requirements are unchanged by the four added fields", () => {
670
+ it("the four are OPT-IN, absent from the default collected set, and never required at submit", async () => {
655
671
  // This assertion was inverted by the back-compat review. The default
656
672
  // `collectedFields` derives from the application schema's own shape, so
657
673
  // adding a field WIDENED what an anonymous caller could write on every
658
674
  // site that never wrote an explicit mask — and the two logo fields ride
659
675
  // the public read and render as image sources. They are excluded from
660
676
  // the default now; a site that wants them names them.
661
- for (const field of ["logoMarkUrl", "logoWordmarkUrl", "requestedWindowDays"]) {
677
+ for (const field of [
678
+ "logoMarkUrl",
679
+ "logoWordmarkUrl",
680
+ "requestedWindowDays",
681
+ "solution",
682
+ ]) {
662
683
  expect(SITE_LISTING_CONFIG_DEFAULTS.collectedFields).not.toContain(field);
663
684
  }
664
685
 
@@ -672,6 +693,7 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
672
693
  expect(rows[0]!["status"]).toBe("requested");
673
694
  expect(rows[0]!["logoMarkUrl"]).toBeNull();
674
695
  expect(rows[0]!["logoWordmarkUrl"]).toBeNull();
696
+ expect(rows[0]!["solution"]).toBeNull();
675
697
  });
676
698
 
677
699
  it("a site whose stored collectedFields predates #5285 submits exactly as it did", async () => {
@@ -712,6 +734,85 @@ describe("the site-answers store's anonymous-write bounds (#5285)", () => {
712
734
  expect(body.error.missingFields).not.toContain("logoMarkUrl");
713
735
  expect(body.error.missingFields).not.toContain("logoWordmarkUrl");
714
736
  expect(body.error.missingFields).not.toContain("requestedWindowDays");
737
+ expect(body.error.missingFields).not.toContain("solution");
738
+ });
739
+ });
740
+
741
+ // ── #5096 — `solution` round-trips for a site that asked ──
742
+
743
+ describe("the `solution` field (#5096)", () => {
744
+ it("patch: persists it onto the row, on a site that asked", async () => {
745
+ configData = { collectedFields: COLLECTED_WITH_OPT_IN };
746
+ const draft = await startDraft(app);
747
+
748
+ const res = await patch(app, draft, { solution: "We publish the answer, not the page." });
749
+
750
+ expect(res.statusCode).toBe(200);
751
+ expect(rows[0]!["solution"]).toBe("We publish the answer, not the page.");
715
752
  });
753
+
754
+ it("patch: refuses an empty solution and one over 600 chars", async () => {
755
+ configData = { collectedFields: COLLECTED_WITH_OPT_IN };
756
+ const draft = await startDraft(app);
757
+
758
+ expect((await patch(app, draft, { solution: "" })).statusCode).toBe(400);
759
+ expect((await patch(app, draft, { solution: "x".repeat(601) })).statusCode).toBe(400);
760
+ expect(rows[0]!["solution"]).toBeNull();
761
+ });
762
+
763
+ // The half that would fail QUIETLY: `submit` validates an application
764
+ // reassembled from the ROW, and a stored value the reassembly drops is
765
+ // indistinguishable — from the founder's side — from one that saved.
766
+ it("submit: the stored solution survives the row-reassembly `submit` validates", async () => {
767
+ configData = { collectedFields: COLLECTED_WITH_OPT_IN };
768
+ const draft = await startDraft(app);
769
+ await patch(app, draft, COMPLETE_APPLICATION);
770
+ await patch(app, draft, { solution: "We publish the answer, not the page." });
771
+
772
+ const res = await submit(app, draft);
773
+
774
+ expect(res.statusCode).toBe(200);
775
+ expect(rows[0]!["status"]).toBe("requested");
776
+ expect(rows[0]!["solution"]).toBe("We publish the answer, not the page.");
777
+ });
778
+ });
779
+ });
780
+
781
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
782
+ //
783
+ // Every case in this file states that this suite's site has a workspace.
784
+ // This one takes that away, and proves the statement was doing work rather
785
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
786
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
787
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
788
+ // permissive default in place this case could not be written at all — every
789
+ // id resolved, so the fail-closed path was unreachable from a route test.
790
+ describe("#6335 — start fails closed when the site names no workspace", () => {
791
+ let app: FastifyInstance;
792
+
793
+ beforeEach(async () => {
794
+ jest.clearAllMocks();
795
+ installStore();
796
+ mockNoWorkspace(prisma);
797
+ app = await buildApp();
798
+ });
799
+
800
+ afterEach(async () => {
801
+ await app.close();
802
+ });
803
+
804
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
805
+ const res = await app.inject({
806
+ method: "POST",
807
+ url: "/v1/project-listings",
808
+ payload: START_PAYLOAD,
809
+ });
810
+
811
+ expect(res.statusCode).toBe(503);
812
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
813
+ "tenant_unresolved",
814
+ );
815
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
816
+ expect(rows).toHaveLength(0);
716
817
  });
717
818
  });
@@ -50,10 +50,17 @@ jest.mock("@working-theory/license", () => ({
50
50
  }),
51
51
  }));
52
52
 
53
+ import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
53
54
  import Fastify from "fastify";
54
55
  import type { FastifyInstance } from "fastify";
55
56
 
57
+ import {
58
+ mockNoWorkspace,
59
+ mockSentinelWorkspaceExists,
60
+ mockWorkspaceExists,
61
+ } from "../../../lib/__mocks__/prisma.js";
56
62
  import { prisma } from "../../../lib/prisma.js";
63
+ import { __resetSiteWorkspaceCacheForTests } from "../../../lib/site.js";
57
64
  import { publicProjectListingsRoute } from "../public.js";
58
65
  import { startProjectListingRoute } from "../start.js";
59
66
 
@@ -106,6 +113,14 @@ function installStore(): void {
106
113
  rows = [];
107
114
  nextId = 1;
108
115
 
116
+ // #6335 — the shared prisma mock no longer resolves ANY requested
117
+ // workspace id, so this suite states the two sites it is built around.
118
+ // `resolveWorkspaceIdForSite` (services/project-listings.ts) keys on the
119
+ // requested id, and nothing else resolves: the env-alias cases below add
120
+ // the ONE further id each of them expects, and the stamp suite's refusal
121
+ // case takes them all away.
122
+ mockWorkspaceExists(prisma, SITE_A, SITE_B);
123
+
109
124
  // One delegate, two lookups (#5048 D2): `where.publicKey` is the site-key
110
125
  // resolution, `where.siteId` the per-site config read. No config row for the
111
126
  // latter, so the platform defaults apply.
@@ -158,6 +173,7 @@ function installStore(): void {
158
173
  headline: null,
159
174
  logoMarkUrl: null,
160
175
  logoWordmarkUrl: null,
176
+ solution: null,
161
177
  featured: false,
162
178
  decidedAt: null,
163
179
  decidedBy: null,
@@ -214,6 +230,7 @@ function seedActiveListing(siteId: string, slug: string): void {
214
230
  questions: null,
215
231
  logoMarkUrl: null,
216
232
  logoWordmarkUrl: null,
233
+ solution: null,
217
234
  featured: false,
218
235
  status: "active",
219
236
  consentAt: new Date("2026-08-01T00:00:00.000Z"),
@@ -254,6 +271,12 @@ describe("the public site key (#5048 D2)", () => {
254
271
  savedPublicKey = process.env["AILK_LISTING_PUBLIC_KEY"];
255
272
  delete process.env["AILK_SITE_ID"];
256
273
  delete process.env["AILK_LISTING_PUBLIC_KEY"];
274
+ // #6254 — `resolveSiteWorkspaceId()` memoizes its FIRST successful
275
+ // resolution for the process lifetime (by design, for the hot request
276
+ // path); several tests in this file change `AILK_SITE_ID` between runs,
277
+ // so the memo must be cleared per test or a later test would silently
278
+ // reuse an earlier test's resolved workspace id.
279
+ __resetSiteWorkspaceCacheForTests();
257
280
  app = await buildApp();
258
281
  });
259
282
 
@@ -331,22 +354,34 @@ describe("the public site key (#5048 D2)", () => {
331
354
  expect(rows.some((r) => r["siteId"] === "the-deployments-own-site")).toBe(false);
332
355
  });
333
356
 
334
- it("start (single-tenant): AILK_LISTING_PUBLIC_KEY resolves to currentSiteId() with no config row", async () => {
357
+ it("start (single-tenant): AILK_LISTING_PUBLIC_KEY resolves to the RESOLVED currentSiteId() workspace, with no config row", async () => {
335
358
  // The default `create-ailk` scaffold is one customer per deployment and
336
359
  // has no `SiteListingConfig` row at all; requiring one would mean a site
337
360
  // could not accept an application until an operator wrote a database row.
361
+ //
362
+ // #6254 AC-6/AC-10 — `siteIdForPublicKey`'s single-tenant fallback now
363
+ // resolves the alias (`resolveSiteWorkspaceId()`) rather than returning
364
+ // the raw `"default"` literal: `project_listings` rejects a `site_id`
365
+ // that disagrees with `workspace_id`
366
+ // (database/migrations/20260922120000_tenancy_fence), and the two must
367
+ // agree from create time.
338
368
  process.env["AILK_LISTING_PUBLIC_KEY"] = "pk-single-tenant";
339
369
  delete process.env["AILK_SITE_ID"]; // → currentSiteId() falls back to "default"
370
+ // #6335 — the "default" alias resolves through the SENTINEL workspace
371
+ // row, which this case (alone in the file) expects to exist.
372
+ mockSentinelWorkspaceExists(prisma, SITE_A, SITE_B);
340
373
 
341
374
  const res = await start(app, { ...START_PAYLOAD, siteKey: "pk-single-tenant" });
342
375
 
343
376
  expect(res.statusCode).toBe(201);
344
- expect(rows[0]!["siteId"]).toBe("default");
377
+ expect(rows[0]!["siteId"]).toBe(DEFAULT_WORKSPACE_ID);
345
378
  });
346
379
 
347
380
  it("start (single-tenant): the env key resolves to AILK_SITE_ID where one is set", async () => {
348
381
  process.env["AILK_LISTING_PUBLIC_KEY"] = "pk-single-tenant";
349
382
  process.env["AILK_SITE_ID"] = "the-only-site";
383
+ // #6335 — this case's own deployment site, named explicitly.
384
+ mockWorkspaceExists(prisma, SITE_A, SITE_B, "the-only-site");
350
385
 
351
386
  const res = await start(app, { ...START_PAYLOAD, siteKey: "pk-single-tenant" });
352
387
 
@@ -397,3 +432,53 @@ describe("the public site key (#5048 D2)", () => {
397
432
  expect(prisma.projectListing.findMany).not.toHaveBeenCalled();
398
433
  });
399
434
  });
435
+
436
+ // ── #6203 AC-4 — the workspace stamp, and its own refusal ──────────────────
437
+ //
438
+ // The site-key resolution above (siteKey -> siteId) is UNCHANGED by #6203 —
439
+ // it was already server-side and already refused a body-supplied override,
440
+ // which the suite above already proves. What #6203 adds is a SECOND, NEW
441
+ // resolution on top of that already-resolved siteId: siteId -> workspaceId
442
+ // (`project-listings.ts`'s `resolveWorkspaceIdForSite`). There is no THIRD
443
+ // body field for an attacker to spoof here (`projectListingStartSchema`
444
+ // carries no workspaceId-shaped field at all, and the suite above already
445
+ // proves a body-supplied `siteId` is refused outright) — so the only new
446
+ // behaviour to assert is this second resolution's own failure mode.
447
+ describe("the workspace stamp (#6203) — start's second resolution, on top of siteKey", () => {
448
+ let app: FastifyInstance;
449
+
450
+ beforeEach(async () => {
451
+ jest.clearAllMocks();
452
+ installStore();
453
+ delete process.env["AILK_SITE_ID"];
454
+ delete process.env["AILK_LISTING_PUBLIC_KEY"];
455
+ __resetSiteWorkspaceCacheForTests();
456
+ app = await buildApp();
457
+ });
458
+
459
+ afterEach(async () => {
460
+ await app.close();
461
+ });
462
+
463
+ it("a siteId that resolves via siteKey but names no workspace is a 503 tenant_unresolved, and writes no row", async () => {
464
+ mockNoWorkspace(prisma);
465
+
466
+ const res = await start(app, { ...START_PAYLOAD, siteKey: KEY_A });
467
+
468
+ expect(res.statusCode).toBe(503);
469
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
470
+ "tenant_unresolved",
471
+ );
472
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
473
+ expect(rows).toHaveLength(0);
474
+ });
475
+
476
+ it("on resolution, the created row carries workspaceId equal to the resolved siteId", async () => {
477
+ const res = await start(app, { ...START_PAYLOAD, siteKey: KEY_B });
478
+
479
+ expect(res.statusCode).toBe(201);
480
+ expect(rows).toHaveLength(1);
481
+ expect(rows[0]!["siteId"]).toBe(SITE_B);
482
+ expect(rows[0]!["workspaceId"]).toBe(SITE_B);
483
+ });
484
+ });
@@ -62,6 +62,10 @@ import Fastify from "fastify";
62
62
  import type { FastifyInstance } from "fastify";
63
63
 
64
64
  import { setupLeadFileEnv } from "../../../__tests__/factories/lead-fixture";
65
+ import {
66
+ mockNoWorkspace,
67
+ mockWorkspaceExists,
68
+ } from "../../../lib/__mocks__/prisma.js";
65
69
  import { prisma } from "../../../lib/prisma.js";
66
70
  import { patchProjectListingDraftRoute } from "../patch-draft.js";
67
71
  import { startProjectListingRoute } from "../start.js";
@@ -178,6 +182,13 @@ function matches(row: Row, where: Record<string, unknown>): boolean {
178
182
  }
179
183
 
180
184
  function installStore(): void {
185
+ // #6335 — the shared prisma mock no longer resolves ANY requested
186
+ // workspace id. `startProjectListingDraft` stamps the row's `workspaceId`
187
+ // by resolving the site it was filed against (`resolveWorkspaceIdForSite`,
188
+ // services/project-listings.ts), so this suite states the ONE site it is
189
+ // built around. Keyed on the requested id — nothing else resolves.
190
+ mockWorkspaceExists(prisma, SITE_ID);
191
+
181
192
  rows = [];
182
193
  nextId = 1;
183
194
 
@@ -243,6 +254,7 @@ function installStore(): void {
243
254
  headline: null,
244
255
  logoMarkUrl: null,
245
256
  logoWordmarkUrl: null,
257
+ solution: null,
246
258
  featured: false,
247
259
  decidedAt: null,
248
260
  decidedBy: null,
@@ -482,3 +494,42 @@ describe("the structured founder address (#5279)", () => {
482
494
  });
483
495
  });
484
496
  });
497
+
498
+ // ── #6335 — the explicit workspace setup above is load-bearing ────────────
499
+ //
500
+ // Every case in this file states that this suite's site has a workspace.
501
+ // This one takes that away, and proves the statement was doing work rather
502
+ // than decorating the setup: `startProjectListingDraft` resolves the site it
503
+ // is filing against (`resolveWorkspaceIdForSite`, services/project-listings.ts)
504
+ // and refuses BEFORE any row is written. With the shared prisma mock's old
505
+ // permissive default in place this case could not be written at all — every
506
+ // id resolved, so the fail-closed path was unreachable from a route test.
507
+ describe("#6335 — start fails closed when the site names no workspace", () => {
508
+ let app: FastifyInstance;
509
+
510
+ beforeEach(async () => {
511
+ jest.clearAllMocks();
512
+ installStore();
513
+ mockNoWorkspace(prisma);
514
+ app = await buildApp();
515
+ });
516
+
517
+ afterEach(async () => {
518
+ await app.close();
519
+ });
520
+
521
+ it("answers 503 tenant_unresolved and creates no ProjectListing", async () => {
522
+ const res = await app.inject({
523
+ method: "POST",
524
+ url: "/v1/project-listings",
525
+ payload: START_PAYLOAD,
526
+ });
527
+
528
+ expect(res.statusCode).toBe(503);
529
+ expect((res.json() as { error: { code: string } }).error.code).toBe(
530
+ "tenant_unresolved",
531
+ );
532
+ expect(prisma.projectListing.create).not.toHaveBeenCalled();
533
+ expect(rows).toHaveLength(0);
534
+ });
535
+ });
@@ -110,7 +110,11 @@ export const startProjectListingRoute: FastifyPluginAsync = async (fastify) => {
110
110
  // #5275 — a `consents` map declining a REQUIRED purpose contradicts the
111
111
  // body's own `consent: true`; no row was created.
112
112
  if (!result.ok) {
113
- return reply.status(400).send({
113
+ // #6203 — `tenant_unresolved` is a server-side tenancy
114
+ // misconfiguration (the resolved `siteId` names no workspace this
115
+ // database holds), not a client input problem — 503, never 400.
116
+ const status = result.error.kind === "tenant_unresolved" ? 503 : 400;
117
+ return reply.status(status).send({
114
118
  ok: false,
115
119
  error: { code: result.error.kind, message: result.error.message },
116
120
  });