create-ailk 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * @file leads-inbox.test.ts
3
+ * @description Unit tests for the leads-inbox read service (#5678).
4
+ *
5
+ * The load-bearing case is ISOLATION: this endpoint returns contact PII, so
6
+ * the claim "a caller cannot read another site's leads" is tested, not argued.
7
+ *
8
+ * External dependency boundary mocked:
9
+ * - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts` manual
10
+ * mock (`jest.mock("../../lib/prisma.js")`).
11
+ *
12
+ * `prisma.lead.findMany` / `findFirst` are given a REALISTIC filtering
13
+ * implementation over an in-memory fixture "table" rather than a static
14
+ * `mockResolvedValue`, mirroring `lead-promotion.test.ts`. That is what makes
15
+ * the isolation test real: a bug that widened the `siteId` match set would
16
+ * surface the other site's lead in the returned page, not merely in an
17
+ * argument-shape assertion.
18
+ *
19
+ * `tenant-context.ts`'s `listTenancy` runs REAL — a pure read over the mocked
20
+ * `prisma.membership.findMany` — so these tests also exercise the
21
+ * "resolve through `lead-promotion.ts`'s `resolveOwnerWorkspaces`" contract
22
+ * rather than re-stubbing it.
23
+ */
24
+
25
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
26
+ jest.mock("../../lib/prisma.js");
27
+
28
+ import type { Id } from "@working-theory/ids";
29
+
30
+ import { prisma } from "../../lib/prisma.js";
31
+ import { listOwnedLeads } from "../leads-inbox";
32
+
33
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
34
+
35
+ const USER_ID = "user_00000000-0000-7000-8000-0000000000a1" as Id<"user">;
36
+ const OWN_ORG_ID = "org_00000000-0000-7000-8000-0000000000b1";
37
+ /** The caller's own workspace — and therefore the `siteId` of their own leads. */
38
+ const OWN_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c1";
39
+ /** A DIFFERENT organization's workspace. The caller holds no membership in it. */
40
+ const OTHER_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000c2";
41
+
42
+ type FakeLeadRow = {
43
+ id: string;
44
+ siteId: string;
45
+ email: string;
46
+ source: string;
47
+ payload: unknown;
48
+ createdAt: Date;
49
+ };
50
+
51
+ /** Shapes `prisma.membership.findMany`'s return — what `listTenancy` reads. */
52
+ function membershipsWithRole(role: string) {
53
+ return [
54
+ {
55
+ role,
56
+ organization: {
57
+ id: OWN_ORG_ID,
58
+ name: "Acme",
59
+ slug: "acme",
60
+ workspaces: [{ id: OWN_SITE_ID, name: "Acme HQ", slug: "acme-hq" }],
61
+ },
62
+ },
63
+ ];
64
+ }
65
+
66
+ function lead(overrides: Partial<FakeLeadRow> & { id: string }): FakeLeadRow {
67
+ return {
68
+ siteId: OWN_SITE_ID,
69
+ email: "contact@example.com",
70
+ source: "web",
71
+ payload: { name: "Ada Lovelace", message: "please call me", phone: "555" },
72
+ createdAt: new Date("2026-09-15T12:00:00.000Z"),
73
+ ...overrides,
74
+ };
75
+ }
76
+
77
+ /**
78
+ * Wires `prisma.lead.findFirst` / `findMany` to a realistic in-memory "table"
79
+ * filtered by the `where` the service passes — so a row outside `siteId.in` is
80
+ * genuinely never returned, exactly as a real WHERE clause would exclude it.
81
+ */
82
+ function seedLeadTable(rows: FakeLeadRow[]) {
83
+ const table = rows.map((r) => ({ ...r }));
84
+
85
+ type Where = {
86
+ siteId: { in: string[] };
87
+ id?: string;
88
+ OR?: Array<{
89
+ createdAt?: Date | { lt: Date };
90
+ id?: { lt: string };
91
+ }>;
92
+ };
93
+
94
+ const inScope = (row: FakeLeadRow, where: Where) =>
95
+ where.siteId.in.includes(row.siteId) &&
96
+ (where.id === undefined || row.id === where.id);
97
+
98
+ const afterCursor = (row: FakeLeadRow, where: Where) => {
99
+ if (!where.OR) return true;
100
+ return where.OR.some((clause) => {
101
+ if (clause.createdAt instanceof Date) {
102
+ return (
103
+ row.createdAt.getTime() === clause.createdAt.getTime() &&
104
+ clause.id !== undefined &&
105
+ row.id < clause.id.lt
106
+ );
107
+ }
108
+ const lt = (clause.createdAt as { lt: Date }).lt;
109
+ return row.createdAt.getTime() < lt.getTime();
110
+ });
111
+ };
112
+
113
+ (prisma.lead.findFirst as jest.Mock).mockImplementation(
114
+ async (args: { where: Where }) =>
115
+ table.find((row) => inScope(row, args.where)) ?? null,
116
+ );
117
+
118
+ (prisma.lead.findMany as jest.Mock).mockImplementation(
119
+ async (args: { where: Where; take: number }) =>
120
+ table
121
+ .filter((row) => inScope(row, args.where) && afterCursor(row, args.where))
122
+ .sort((a, b) =>
123
+ b.createdAt.getTime() - a.createdAt.getTime() || (a.id < b.id ? 1 : -1),
124
+ )
125
+ .slice(0, args.take),
126
+ );
127
+
128
+ return table;
129
+ }
130
+
131
+ describe("listOwnedLeads", () => {
132
+ // The workspace resolution is module-conditional (isModuleEnabled
133
+ // 'multi-tenant' — see lead-promotion.ts), so every test that expects rows
134
+ // needs the module explicitly enabled: AILK_ENABLED_MODULES unset means no
135
+ // paid modules (D-44, #5368).
136
+ const priorEnabledModules = process.env.AILK_ENABLED_MODULES;
137
+
138
+ beforeEach(() => {
139
+ jest.clearAllMocks();
140
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
141
+ });
142
+
143
+ afterAll(() => {
144
+ if (priorEnabledModules === undefined) {
145
+ delete process.env.AILK_ENABLED_MODULES;
146
+ } else {
147
+ process.env.AILK_ENABLED_MODULES = priorEnabledModules;
148
+ }
149
+ });
150
+
151
+ // ── ISOLATION (load-bearing) ───────────────────────────────────────────────
152
+ it("never returns a lead whose siteId belongs to another site", async () => {
153
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
154
+ membershipsWithRole("owner"), // the caller belongs ONLY to OWN_ORG_ID
155
+ );
156
+ seedLeadTable([
157
+ lead({
158
+ id: "lead_own_1",
159
+ siteId: OWN_SITE_ID,
160
+ email: "mine@example.com",
161
+ createdAt: new Date("2026-09-15T12:00:00.000Z"),
162
+ }),
163
+ lead({
164
+ id: "lead_other_1",
165
+ siteId: OTHER_SITE_ID,
166
+ email: "theirs@example.com",
167
+ createdAt: new Date("2026-09-15T13:00:00.000Z"), // NEWER — would sort first
168
+ }),
169
+ ]);
170
+
171
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
172
+
173
+ expect(result.ok).toBe(true);
174
+ if (!result.ok) return;
175
+ expect(result.value.items.map((row) => row.id)).toEqual(["lead_own_1"]);
176
+ expect(JSON.stringify(result.value)).not.toContain("theirs@example.com");
177
+ expect(JSON.stringify(result.value)).not.toContain(OTHER_SITE_ID);
178
+ // The scope reaches the DB as a filter, not a post-filter in JS.
179
+ expect(prisma.lead.findMany).toHaveBeenCalledWith(
180
+ expect.objectContaining({ where: expect.objectContaining({ siteId: { in: [OWN_SITE_ID] } }) }),
181
+ );
182
+ });
183
+
184
+ it("returns an empty page when the caller belongs to no workspace at all", async () => {
185
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue([]);
186
+ seedLeadTable([lead({ id: "lead_other_1", siteId: OTHER_SITE_ID })]);
187
+
188
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
189
+
190
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
191
+ // No workspace means no query at all — not a query with an empty `in`.
192
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
193
+ });
194
+
195
+ it("returns an empty page for a `member` — the role floor is owner|admin", async () => {
196
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
197
+ membershipsWithRole("member"),
198
+ );
199
+ seedLeadTable([lead({ id: "lead_own_1", siteId: OWN_SITE_ID })]);
200
+
201
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
202
+
203
+ expect(result).toEqual({ ok: true, value: { items: [], nextCursor: null } });
204
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
205
+ });
206
+
207
+ it("rejects a cursor that resolves outside the caller's scope", async () => {
208
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
209
+ membershipsWithRole("admin"),
210
+ );
211
+ seedLeadTable([
212
+ lead({ id: "lead_own_1", siteId: OWN_SITE_ID }),
213
+ lead({ id: "lead_other_1", siteId: OTHER_SITE_ID }),
214
+ ]);
215
+
216
+ const result = await listOwnedLeads({
217
+ userId: USER_ID,
218
+ cursor: "lead_other_1",
219
+ limit: 50,
220
+ });
221
+
222
+ expect(result).toEqual({
223
+ ok: false,
224
+ error: {
225
+ kind: "invalid_cursor",
226
+ message: "cursor does not resolve within this scope",
227
+ },
228
+ });
229
+ // The rejection is indistinguishable from "no such lead" — a foreign
230
+ // cursor is never a probe that confirms another site's row exists.
231
+ expect(prisma.lead.findMany).not.toHaveBeenCalled();
232
+ });
233
+
234
+ // ── Row shape (the PII floor) ──────────────────────────────────────────────
235
+ it("returns exactly the five rendered fields — never the rest of the record", async () => {
236
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
237
+ membershipsWithRole("owner"),
238
+ );
239
+ seedLeadTable([
240
+ lead({
241
+ id: "lead_own_1",
242
+ email: "ada@example.com",
243
+ source: "conversational-intake",
244
+ payload: { name: "Ada Lovelace", message: "please call me", phone: "555-0100" },
245
+ }),
246
+ ]);
247
+
248
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
249
+
250
+ expect(result.ok).toBe(true);
251
+ if (!result.ok) return;
252
+ expect(result.value.items).toEqual([
253
+ {
254
+ id: "lead_own_1",
255
+ name: "Ada Lovelace",
256
+ email: "ada@example.com",
257
+ source: "conversational-intake",
258
+ createdAt: "2026-09-15T12:00:00.000Z",
259
+ },
260
+ ]);
261
+ // The rest of `payload` is never on the wire.
262
+ const serialized = JSON.stringify(result.value);
263
+ expect(serialized).not.toContain("please call me");
264
+ expect(serialized).not.toContain("555-0100");
265
+ expect(serialized).not.toContain("siteId");
266
+ });
267
+
268
+ it("truncates an unbounded name — one anonymous write cannot size the page", async () => {
269
+ // PR #5705 security review, finding 1: `boundedLeadPayload` bounds the
270
+ // payload at 64 KEYS and nothing else, and `POST /v1/leads/submit` is
271
+ // anonymous with no ownership check on `siteId`, so a stranger can plant a
272
+ // ~1 MB `name` in the owner's inbox. Ordering is `createdAt desc` and this
273
+ // unit ships no delete surface, so it would sit on page one for good.
274
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
275
+ membershipsWithRole("owner"),
276
+ );
277
+ seedLeadTable([
278
+ lead({ id: "lead_own_1", payload: { name: "A".repeat(900_000) } }),
279
+ ]);
280
+
281
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
282
+
283
+ expect(result.ok).toBe(true);
284
+ if (!result.ok) return;
285
+ expect(result.value.items[0]?.name).toBe("A".repeat(200));
286
+ expect(JSON.stringify(result.value).length).toBeLessThan(1000);
287
+ });
288
+
289
+ it("reads `name` as null when the site's form captured none", async () => {
290
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
291
+ membershipsWithRole("owner"),
292
+ );
293
+ seedLeadTable([lead({ id: "lead_own_1", payload: { company: "Acme" } })]);
294
+
295
+ const result = await listOwnedLeads({ userId: USER_ID, limit: 50 });
296
+
297
+ expect(result.ok).toBe(true);
298
+ if (!result.ok) return;
299
+ expect(result.value.items[0]?.name).toBeNull();
300
+ });
301
+
302
+ // ── Pagination ─────────────────────────────────────────────────────────────
303
+ it("pages newest first and hands back the last row's id as nextCursor", async () => {
304
+ (prisma.membership.findMany as jest.Mock).mockResolvedValue(
305
+ membershipsWithRole("owner"),
306
+ );
307
+ seedLeadTable([
308
+ lead({ id: "lead_a", createdAt: new Date("2026-09-15T10:00:00.000Z") }),
309
+ lead({ id: "lead_b", createdAt: new Date("2026-09-15T11:00:00.000Z") }),
310
+ lead({ id: "lead_c", createdAt: new Date("2026-09-15T12:00:00.000Z") }),
311
+ ]);
312
+
313
+ const first = await listOwnedLeads({ userId: USER_ID, limit: 2 });
314
+ expect(first.ok).toBe(true);
315
+ if (!first.ok) return;
316
+ expect(first.value.items.map((row) => row.id)).toEqual(["lead_c", "lead_b"]);
317
+ expect(first.value.nextCursor).toBe("lead_b");
318
+
319
+ const second = await listOwnedLeads({
320
+ userId: USER_ID,
321
+ cursor: first.value.nextCursor ?? undefined,
322
+ limit: 2,
323
+ });
324
+ expect(second.ok).toBe(true);
325
+ if (!second.ok) return;
326
+ expect(second.value.items.map((row) => row.id)).toEqual(["lead_a"]);
327
+ expect(second.value.nextCursor).toBeNull();
328
+ });
329
+ });
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @file listing-config.test.ts
3
+ * @description Unit tests for the per-site project-listings config reads
4
+ * (#5033) under the tenancy fence (#6257, Stage 2 unit C2).
5
+ *
6
+ * This suite is NEW. `listing-config.ts`'s only coverage lived under
7
+ * `apps/api/src/routes/**\/__tests__/`, which this unit must not touch — so
8
+ * the two reads it owns are pinned here instead, at the service boundary
9
+ * where the fence is applied.
10
+ *
11
+ * Both reads run on the ANONYMOUS path, with no authenticated tenant:
12
+ *
13
+ * - `listingConfigFor(siteId)` is handed an ALREADY-RESOLVED workspace id
14
+ * by every caller (a listing row's own `siteId`, a tenant's
15
+ * `workspaceId`, or `siteIdForPublicKey`'s return), so its read is
16
+ * scoped by it.
17
+ * - `siteIdForPublicKey(publicKey)` is the query that RESOLVES the tenant.
18
+ * It is the documented global-key exemption (its call site carries the
19
+ * marker comment): it is keyed on a
20
+ * site's public listing key, which names one row across all workspaces,
21
+ * and a tenant term would be the very thing it exists to produce.
22
+ *
23
+ * External dependency boundaries mocked:
24
+ * - @working-theory/database (via moduleNameMapper → prisma-client.js mock;
25
+ * prisma singleton via __mocks__/prisma.ts)
26
+ * - @working-theory/observability logger
27
+ */
28
+
29
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
30
+ jest.mock("../../lib/prisma.js");
31
+
32
+ const mockLoggerWarn = jest.fn();
33
+ const mockLoggerError = jest.fn();
34
+ jest.mock("@working-theory/observability", () => ({
35
+ logger: {
36
+ info: jest.fn(),
37
+ warn: (...args: unknown[]) => mockLoggerWarn(...args),
38
+ error: (...args: unknown[]) => mockLoggerError(...args),
39
+ debug: jest.fn(),
40
+ },
41
+ }));
42
+
43
+ import { SITE_LISTING_CONFIG_DEFAULTS } from "@working-theory/validation";
44
+
45
+ import { prisma } from "../../lib/prisma.js";
46
+ import { __resetSiteWorkspaceCacheForTests } from "../../lib/site.js";
47
+ import { listingConfigFor, siteIdForPublicKey } from "../listing-config";
48
+
49
+ const SITE_ID = "wksp_00000000-0000-7000-8000-0000000000a1";
50
+ const OTHER_SITE_ID = "wksp_00000000-0000-7000-8000-0000000000ff";
51
+ const PUBLIC_KEY = "pk_listing_abcdef";
52
+
53
+ const priorPublicKey = process.env["AILK_LISTING_PUBLIC_KEY"];
54
+ const priorSiteId = process.env["AILK_SITE_ID"];
55
+
56
+ beforeEach(() => {
57
+ jest.clearAllMocks();
58
+ __resetSiteWorkspaceCacheForTests();
59
+ delete process.env["AILK_LISTING_PUBLIC_KEY"];
60
+ delete process.env["AILK_SITE_ID"];
61
+ });
62
+
63
+ afterAll(() => {
64
+ if (priorPublicKey === undefined) delete process.env["AILK_LISTING_PUBLIC_KEY"];
65
+ else process.env["AILK_LISTING_PUBLIC_KEY"] = priorPublicKey;
66
+ if (priorSiteId === undefined) delete process.env["AILK_SITE_ID"];
67
+ else process.env["AILK_SITE_ID"] = priorSiteId;
68
+ });
69
+
70
+ // ─── listingConfigFor — the fenced read ────────────────────────────────────
71
+
72
+ describe("listingConfigFor", () => {
73
+ it("scopes the config read by the workspace id it was handed (#6257 AC-1/AC-2)", async () => {
74
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
75
+ data: { maxWindowDays: 60, defaultWindowDays: 21 },
76
+ });
77
+
78
+ await listingConfigFor(SITE_ID);
79
+
80
+ expect(prisma.siteListingConfig.findUnique).toHaveBeenCalledTimes(1);
81
+ const { where } = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls[0][0];
82
+ expect(where.workspaceId).toBe(SITE_ID);
83
+ expect(where.siteId).toBe(SITE_ID);
84
+ });
85
+
86
+ it("reads a DIFFERENT site under that site's own workspace id, never a remembered one", async () => {
87
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
88
+
89
+ await listingConfigFor(SITE_ID);
90
+ await listingConfigFor(OTHER_SITE_ID);
91
+
92
+ const calls = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls;
93
+ expect(calls[0][0].where.workspaceId).toBe(SITE_ID);
94
+ expect(calls[1][0].where.workspaceId).toBe(OTHER_SITE_ID);
95
+ });
96
+
97
+ it("a site with no row gets the platform defaults", async () => {
98
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
99
+
100
+ await expect(listingConfigFor(SITE_ID)).resolves.toEqual(
101
+ SITE_LISTING_CONFIG_DEFAULTS,
102
+ );
103
+ });
104
+
105
+ it("a stored blob that fails its schema gets the platform defaults, logged at ERROR", async () => {
106
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
107
+ data: { maxWindowDays: "not-a-number" },
108
+ });
109
+
110
+ await expect(listingConfigFor(SITE_ID)).resolves.toEqual(
111
+ SITE_LISTING_CONFIG_DEFAULTS,
112
+ );
113
+ expect(mockLoggerError).toHaveBeenCalledWith(
114
+ expect.objectContaining({ siteId: SITE_ID }),
115
+ expect.stringContaining("PLATFORM defaults"),
116
+ );
117
+ });
118
+ });
119
+
120
+ // ─── siteIdForPublicKey — the documented global-key exemption ──────────────
121
+
122
+ describe("siteIdForPublicKey", () => {
123
+ it("is keyed on the public listing key ALONE — the query that resolves the tenant carries no tenant term", async () => {
124
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue({
125
+ siteId: SITE_ID,
126
+ });
127
+
128
+ await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBe(SITE_ID);
129
+
130
+ const { where } = (prisma.siteListingConfig.findUnique as jest.Mock).mock.calls[0][0];
131
+ expect(where).toEqual({ publicKey: PUBLIC_KEY });
132
+ expect(where.workspaceId).toBeUndefined();
133
+ });
134
+
135
+ it("an unmatched key resolves to null rather than falling through to the env path", async () => {
136
+ process.env["AILK_LISTING_PUBLIC_KEY"] = "pk_env_key";
137
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
138
+
139
+ await expect(siteIdForPublicKey("pk_typo")).resolves.toBeNull();
140
+ });
141
+
142
+ it("the env single-tenant path returns the RESOLVED workspace id, never the raw alias", async () => {
143
+ process.env["AILK_LISTING_PUBLIC_KEY"] = PUBLIC_KEY;
144
+ process.env["AILK_SITE_ID"] = SITE_ID;
145
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
146
+ // Keyed on the REQUESTED id — a blanket resolve would make a fail-closed
147
+ // assertion impossible (#6374's own test convention).
148
+ (prisma.workspace.findUnique as jest.Mock).mockImplementation(
149
+ async ({ where }: { where: { id: string } }) =>
150
+ where.id === SITE_ID
151
+ ? { id: SITE_ID, organizationId: "org_00000000-0000-7000-8000-000000000001" }
152
+ : null,
153
+ );
154
+
155
+ await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBe(SITE_ID);
156
+ });
157
+
158
+ it("fails closed when the env alias names no workspace", async () => {
159
+ process.env["AILK_LISTING_PUBLIC_KEY"] = PUBLIC_KEY;
160
+ process.env["AILK_SITE_ID"] = "no-such-workspace";
161
+ (prisma.siteListingConfig.findUnique as jest.Mock).mockResolvedValue(null);
162
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue(null);
163
+
164
+ await expect(siteIdForPublicKey(PUBLIC_KEY)).resolves.toBeNull();
165
+ });
166
+ });
@@ -0,0 +1,154 @@
1
+ // oss: false
2
+ /**
3
+ * @file project-listings.fence.test.ts
4
+ *
5
+ * #6258 AC-6 — `attachWaitlistCounts` (`services/project-listings.ts:425-443`)
6
+ * is one of the nine fenced reads that gain a `workspaceId` term beside their
7
+ * existing `siteId` one. This suite is that read's per-`where` proof.
8
+ *
9
+ * Why a NEW file rather than an addition to an existing suite: this service's
10
+ * only coverage today lives under `apps/api/src/routes/project-listings/__tests__/`,
11
+ * a path under the advisory route floor this unit keeps its diff out of
12
+ * (spec § 8.3). The `where` lives in the service, so the service is where it
13
+ * is proven.
14
+ */
15
+ import { listProjectListings } from "../project-listings.js";
16
+
17
+ // Boundary mocks — `project-listings.ts`'s import chain reaches the Resend
18
+ // client and the licence check; neither is exercised by the read under test.
19
+ jest.mock("@working-theory/email", () => ({
20
+ sendLeadNotification: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
21
+ sendDeliverableEmail: jest.fn().mockResolvedValue({ ok: true, messageId: "msg-test" }),
22
+ }));
23
+
24
+ jest.mock("@working-theory/license", () => ({
25
+ checkLicense: jest.fn().mockResolvedValue({
26
+ tier: "free",
27
+ plan: null,
28
+ features: [],
29
+ expiresAt: null,
30
+ revoked: false,
31
+ }),
32
+ }));
33
+
34
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
35
+ jest.mock("../../lib/prisma.js", () => ({
36
+ prisma: {
37
+ projectListing: { findMany: jest.fn() },
38
+ waitlistScore: { count: jest.fn() },
39
+ },
40
+ }));
41
+
42
+ import { prisma } from "../../lib/prisma.js";
43
+
44
+ const SITE = "wksp_00000000-0000-7000-8000-000000000001";
45
+ const EXPERIMENT_A = "ailk-founder-diagnostic";
46
+ const EXPERIMENT_B = "ailk-agency-waitlist";
47
+
48
+ /**
49
+ * A listing row carrying `workspaceId` equal to its `siteId` — exactly what
50
+ * the `project_listings_workspace_site_agree` CHECK guarantees in the
51
+ * database (#6258 D11).
52
+ */
53
+ function listingRow(id: string, experimentKey: string | null): Record<string, unknown> {
54
+ return {
55
+ id,
56
+ siteId: SITE,
57
+ workspaceId: SITE,
58
+ leadId: null,
59
+ slug: `slug-${id}`,
60
+ name: "Acme",
61
+ headline: null,
62
+ oneLiner: null,
63
+ problem: null,
64
+ solution: null,
65
+ url: null,
66
+ product: null,
67
+ commitment: null,
68
+ teamComposition: null,
69
+ faqs: null,
70
+ anythingElse: null,
71
+ address: null,
72
+ addressLine1: null,
73
+ addressLine2: null,
74
+ addressCity: null,
75
+ addressRegion: null,
76
+ addressPostalCode: null,
77
+ addressCountry: null,
78
+ questions: null,
79
+ siteAnswers: null,
80
+ windowDays: null,
81
+ founderName: null,
82
+ founderFirstName: null,
83
+ founderLastName: null,
84
+ founderEmail: null,
85
+ founderPhone: null,
86
+ founderCompany: null,
87
+ emailVerifiedAt: null,
88
+ logoMarkUrl: null,
89
+ logoWordmarkUrl: null,
90
+ featured: false,
91
+ status: "active",
92
+ consentAt: new Date("2026-09-01T00:00:00.000Z"),
93
+ decidedAt: null,
94
+ decidedBy: null,
95
+ issueUrl: null,
96
+ experimentKey,
97
+ windowEndsAt: new Date("2099-01-01T00:00:00.000Z"),
98
+ createdAt: new Date("2026-09-01T00:00:00.000Z"),
99
+ updatedAt: new Date("2026-09-01T00:00:00.000Z"),
100
+ };
101
+ }
102
+
103
+ beforeEach(() => {
104
+ jest.clearAllMocks();
105
+ (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(3);
106
+ });
107
+
108
+ describe("attachWaitlistCounts — the fenced count filters on workspaceId beside siteId (#6258 AC-6)", () => {
109
+ it("prisma.waitlistScore.count's where carries workspaceId === the resolved scope id, by value", async () => {
110
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
111
+ listingRow("plst_a", EXPERIMENT_A),
112
+ ]);
113
+
114
+ await listProjectListings(SITE);
115
+
116
+ expect((prisma.waitlistScore.count as jest.Mock).mock.calls[0]![0]!.where).toEqual({
117
+ siteId: SITE,
118
+ workspaceId: SITE,
119
+ experimentKey: EXPERIMENT_A,
120
+ });
121
+ });
122
+
123
+ it("every DISTINCT experimentKey gets the same two scope terms — one count per key, no more", async () => {
124
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
125
+ listingRow("plst_a", EXPERIMENT_A),
126
+ listingRow("plst_b", EXPERIMENT_B),
127
+ listingRow("plst_c", EXPERIMENT_A),
128
+ ]);
129
+
130
+ await listProjectListings(SITE);
131
+
132
+ expect(prisma.waitlistScore.count).toHaveBeenCalledTimes(2);
133
+ const wheres = (prisma.waitlistScore.count as jest.Mock).mock.calls.map(
134
+ (call) => call[0]!.where as Record<string, unknown>,
135
+ );
136
+ for (const where of wheres) {
137
+ expect(where.siteId).toBe(SITE);
138
+ expect(where.workspaceId).toBe(SITE);
139
+ }
140
+ expect(wheres.map((w) => w.experimentKey).sort()).toEqual(
141
+ [EXPERIMENT_A, EXPERIMENT_B].sort(),
142
+ );
143
+ });
144
+
145
+ it("no listing carries an experimentKey → no fenced read is issued at all", async () => {
146
+ (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
147
+ listingRow("plst_a", null),
148
+ ]);
149
+
150
+ await listProjectListings(SITE);
151
+
152
+ expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
153
+ });
154
+ });