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
@@ -58,11 +58,20 @@ const SHARED_TENANT_ROUTES = [
58
58
  { method: 'GET' as const, url: `/v1/workspaces/${WS}/members` },
59
59
  ];
60
60
 
61
- /** Routes that must stay multi-tenant-only — the gate's other half. */
61
+ /**
62
+ * Routes that must NOT register under `workspace` alone — the gate's other
63
+ * half. `/v1/waitlist-experiments` is on this list because the review surface
64
+ * is gated on `multi-tenant || signal-collectors` (#5677), neither of which is
65
+ * enabled in this file's `workspace`-only cases; it is no longer
66
+ * multi-tenant-EXCLUSIVE, and its own matrix is
67
+ * `review-surface-registration.test.ts`.
68
+ */
62
69
  const MULTI_TENANT_ONLY_ROUTES = [
63
70
  { method: 'GET' as const, url: '/v1/workspaces' },
64
71
  { method: 'GET' as const, url: '/v1/billing' },
65
72
  { method: 'GET' as const, url: '/v1/waitlist-experiments' },
73
+ { method: 'POST' as const, url: `/v1/workspaces/${WS}/activate` },
74
+ { method: 'GET' as const, url: `/v1/workspaces/${WS}/sessions` },
66
75
  ];
67
76
 
68
77
  async function withEnabledModules<T>(
@@ -54,6 +54,41 @@ export interface Config {
54
54
  // origin, which stays correct for any agent that fetched the spec from this
55
55
  // API. See resolveServers() in src/openapi/spec.ts.
56
56
  AILK_PUBLIC_API_URL?: string;
57
+
58
+ // Request-and-confirm (#5970) — the callback → confirm → Payment Link path.
59
+ // All five optional and all read through services/request-confirm.ts, never
60
+ // from here directly; they are declared so the env contract is complete and
61
+ // one place lists them.
62
+ //
63
+ // AILK_REQUEST_CONFIRM_ENABLED is the switch: "true" turns the path on,
64
+ // anything else (including absent, the default) leaves the callback route
65
+ // behaving byte-for-byte as it did before the feature existed. TO and FROM
66
+ // fall back to the AILK_LEAD_NOTIFICATION_* pair; the link's base is the
67
+ // existing AILK_PUBLIC_API_URL above, since the confirm route is on this
68
+ // API. None of the five is a secret, so project.yaml's required_secrets
69
+ // gains nothing.
70
+ AILK_REQUEST_CONFIRM_ENABLED?: string;
71
+ AILK_REQUEST_CONFIRM_TO?: string;
72
+ AILK_REQUEST_CONFIRM_FROM?: string;
73
+ AILK_REQUEST_CONFIRM_CURRENCY?: string;
74
+ AILK_REQUEST_CONFIRM_MAX_MINOR?: string;
75
+
76
+ // Publish revalidation (#5851) — the signed webhook services/revalidation.ts
77
+ // POSTs at the site's /api/revalidate on publish/unpublish.
78
+ // CONTENT_REVALIDATE_SECRET is the HMAC-SHA256 signing key. Both optional;
79
+ // either absent means "revalidation off" — the service is a logged no-op and
80
+ // the publish that called it still completes.
81
+ CONTENT_REVALIDATE_URL?: string;
82
+ CONTENT_REVALIDATE_SECRET?: string;
83
+
84
+ // Scheduling webhook receiver (#6085, brief H7) — the HMAC-SHA256 key
85
+ // routes/webhooks/calcom.ts verifies POST /v1/webhooks/calcom's
86
+ // x-cal-signature-256 header against (§ D2, § D14). Read the same way
87
+ // STRIPE_WEBHOOK_SECRET is above — through `config`, never `process.env`
88
+ // at the call site. Unset makes the receiver answer 503 and accept
89
+ // nothing (§ D2 row 1) — the same fail-closed posture STRIPE_WEBHOOK_SECRET
90
+ // unset already has.
91
+ CAL_WEBHOOK_SECRET?: string;
57
92
  }
58
93
 
59
94
  function validateConfig(): Config {
@@ -104,6 +139,33 @@ function validateConfig(): Config {
104
139
  ...(process.env.AILK_PUBLIC_API_URL !== undefined && {
105
140
  AILK_PUBLIC_API_URL: process.env.AILK_PUBLIC_API_URL,
106
141
  }),
142
+
143
+ ...(process.env.AILK_REQUEST_CONFIRM_ENABLED !== undefined && {
144
+ AILK_REQUEST_CONFIRM_ENABLED: process.env.AILK_REQUEST_CONFIRM_ENABLED,
145
+ }),
146
+ ...(process.env.AILK_REQUEST_CONFIRM_TO !== undefined && {
147
+ AILK_REQUEST_CONFIRM_TO: process.env.AILK_REQUEST_CONFIRM_TO,
148
+ }),
149
+ ...(process.env.AILK_REQUEST_CONFIRM_FROM !== undefined && {
150
+ AILK_REQUEST_CONFIRM_FROM: process.env.AILK_REQUEST_CONFIRM_FROM,
151
+ }),
152
+ ...(process.env.AILK_REQUEST_CONFIRM_CURRENCY !== undefined && {
153
+ AILK_REQUEST_CONFIRM_CURRENCY: process.env.AILK_REQUEST_CONFIRM_CURRENCY,
154
+ }),
155
+ ...(process.env.AILK_REQUEST_CONFIRM_MAX_MINOR !== undefined && {
156
+ AILK_REQUEST_CONFIRM_MAX_MINOR: process.env.AILK_REQUEST_CONFIRM_MAX_MINOR,
157
+ }),
158
+
159
+ ...(process.env.CONTENT_REVALIDATE_URL !== undefined && {
160
+ CONTENT_REVALIDATE_URL: process.env.CONTENT_REVALIDATE_URL,
161
+ }),
162
+ ...(process.env.CONTENT_REVALIDATE_SECRET !== undefined && {
163
+ CONTENT_REVALIDATE_SECRET: process.env.CONTENT_REVALIDATE_SECRET,
164
+ }),
165
+
166
+ ...(process.env.CAL_WEBHOOK_SECRET !== undefined && {
167
+ CAL_WEBHOOK_SECRET: process.env.CAL_WEBHOOK_SECRET,
168
+ }),
107
169
  };
108
170
  }
109
171
 
@@ -12,9 +12,13 @@
12
12
  * Recognized module keys: `docs`, `multi-tenant`, `workspace`, `scheduling`,
13
13
  * `signal-collectors`, `commerce` (the `ModuleName` vocabulary
14
14
  * `packages/create-ailk/src/module-architecture.ts` already models via
15
- * `MODULE_SLICES`) plus `content-write` — the Pro
16
- * content-write surface (`create`/`update`/`delete` under
17
- * `apps/api/src/routes/content/`). `content-write` is intentionally NOT a
15
+ * `MODULE_SLICES`) plus `content-write` — the Pro content-write surface,
16
+ * which since #5853 means the tenant-guarded editor routes in
17
+ * `apps/api/src/routes/workspaces/content.ts` (the untenanted
18
+ * `create`/`update`/`delete` under `apps/api/src/routes/content/` that it used
19
+ * to name were retired by deletion). The flag's NAME, registration grain and
20
+ * default-off semantics are unchanged (D8) — only its documented meaning
21
+ * widened. `content-write` is intentionally NOT a
18
22
  * `MODULE_SLICES` entry: it is not (yet) a `create-ailk --modules`
19
23
  * selector — a customer never opts into it at scaffold time, it is a
20
24
  * license-tier line drawn inside the single `apps/api` app — so it stays a
@@ -27,7 +27,14 @@
27
27
  * idempotency reader) + callback.findMany (booked-call suppression) +
28
28
  * waitlistScore.findMany (tier lookup) — used by the #4732
29
29
  * waitlist-followup sweep service tests
30
- * tenancy models (workspace, membership) — used by tenant-context service tests (#3627)
30
+ * tenancy models (workspace, membership) — used by tenant-context service tests (#3627).
31
+ * workspace.findUnique carries NO default (#6335) — see its own comment
32
+ * below. A suite whose route resolves a workspace states the shape it
33
+ * expects through `mockWorkspaceExists()` / `mockNoWorkspace()`, exported
34
+ * at the foot of this file
35
+ * artifact models (artifact, artifactVersion) — the database content
36
+ * adapter's read surface, used by the public-site-resolver + content route
37
+ * tests (#5852)
31
38
  * membership.findMany + lead.findMany/updateMany — used by lead-promotion
32
39
  * service tests (#3586); lead-promotion.ts never queries membership/workspace
33
40
  * directly (it resolves workspaces via tenant-context.ts's listTenancy), so
@@ -49,6 +56,10 @@
49
56
  * #3739 M2 — was `findMany` + a JS accumulation loop). Same not-yet-
50
57
  * generated-client situation; `usage-aggregation.ts` casts `prisma` to its
51
58
  * own structural delegate type, and this mock's runtime shape matches it.
59
+ *
60
+ * userData.create/updateMany (auth) + siteSetting.findUnique (config) — the
61
+ * dunning record's compare-and-swap write and the per-tenant grace-policy
62
+ * read (#6034), used by dunning-policy.test.ts and stripe-dunning.test.ts.
52
63
  */
53
64
 
54
65
  export const prisma = {
@@ -68,6 +79,13 @@ export const prisma = {
68
79
  findUnique: jest.fn(),
69
80
  upsert: jest.fn(),
70
81
  deleteMany: jest.fn(),
82
+ // `create` / `updateMany` — the dunning record's compare-and-swap write
83
+ // (#6034 § D6): the webhook reads `{value, updatedAt}` then either
84
+ // `create`s a fresh row or `updateMany`s guarded on BOTH observed
85
+ // columns, so a concurrent writer makes the predicate miss (`count: 0`)
86
+ // rather than silently losing a revocation.
87
+ create: jest.fn(),
88
+ updateMany: jest.fn(),
71
89
  },
72
90
  // ── Tenancy namespace (Organization, Workspace, Membership) ──────────────
73
91
  organization: {
@@ -84,6 +102,29 @@ export const prisma = {
84
102
  updateMany: jest.fn(),
85
103
  },
86
104
  workspace: {
105
+ // #6335 — NO default implementation, deliberately. Until this issue the
106
+ // delegate carried a PERMISSIVE one (#6203): it resolved ANY requested
107
+ // id as an existing workspace with a sentinel-shaped organizationId, so
108
+ // every tenancy resolver in a route test saw "this workspace exists" no
109
+ // matter what the test had set up. That is DEF-014 — a fixture that
110
+ // fabricates the condition under test: a route that should have failed
111
+ // closed passed, and every isolation-shaped test written against this
112
+ // mock inherited a fake success.
113
+ //
114
+ // A bare `jest.fn()` resolves `undefined`, which every resolver in
115
+ // `apps/api/src/lib/site.ts` reads as "no such workspace" and rejects —
116
+ // so a suite that needs a workspace to resolve must SAY SO, keyed on the
117
+ // id it actually expects to be asked for:
118
+ //
119
+ // mockWorkspaceExists(prisma, SITE_ID) // only SITE_ID resolves
120
+ // mockSentinelWorkspaceExists(prisma) // the "default" alias path
121
+ // mockNoWorkspace(prisma) // nothing resolves
122
+ //
123
+ // Keying on the requested id is the load-bearing part.
124
+ // `resolveWorkspaceIdForAlias` returns the ALIAS on mere truthiness of
125
+ // the row (`site.ts`) — it never reads the row's own id — so a mock that
126
+ // resolves unconditionally is permissive by accident even when it hands
127
+ // back a specific-looking row.
87
128
  findUnique: jest.fn(),
88
129
  count: jest.fn(),
89
130
  },
@@ -92,6 +133,18 @@ export const prisma = {
92
133
  findMany: jest.fn(),
93
134
  create: jest.fn(),
94
135
  },
136
+ // ── Artifact store (the database content adapter's read surface, #5852) ──
137
+ // Only the two readers `ArtifactPrismaClient` declares for reads
138
+ // (packages/content-adapters/src/database/adapter.ts): the public content
139
+ // routes never write, so no `create`/`updateMany`/`deleteMany` is faked here.
140
+ artifact: {
141
+ findFirst: jest.fn(),
142
+ findMany: jest.fn(),
143
+ },
144
+ artifactVersion: {
145
+ findFirst: jest.fn(),
146
+ findMany: jest.fn(),
147
+ },
95
148
  // ── Config namespace (per-workspace site config — #3641/#3634) ───────────
96
149
  workspaceTheme: {
97
150
  findUnique: jest.fn(),
@@ -114,6 +167,9 @@ export const prisma = {
114
167
  // `findUnique` — used by the deliverable-resend service (#3998) to
115
168
  // resolve a grant's stored lead email.
116
169
  findUnique: jest.fn(),
170
+ // `findFirst` — the leads-inbox cursor lookup (#5678), which resolves a
171
+ // supplied cursor WITHIN the caller's scope before paging from it.
172
+ findFirst: jest.fn(),
117
173
  findMany: jest.fn(),
118
174
  updateMany: jest.fn(),
119
175
  },
@@ -124,9 +180,16 @@ export const prisma = {
124
180
  // `findMany` — the #4732 follow-up sweep's booked-call suppression check
125
181
  // (D2: a site's Callback rows fetched once per sweep run, filtered in
126
182
  // application code for `payload.email` matches).
183
+ // findFirst / update / updateMany joined for the #5970 request-and-confirm
184
+ // lifecycle: the credential mint and the confirmation claim are both guarded
185
+ // `updateMany`s, the GET resolves through `findFirst`, and the Stripe
186
+ // artifact id lands via `update`.
127
187
  callback: {
128
188
  create: jest.fn(),
189
+ findFirst: jest.fn(),
129
190
  findMany: jest.fn(),
191
+ update: jest.fn(),
192
+ updateMany: jest.fn(),
130
193
  },
131
194
  // ── Inbox namespace (FlowDefinition, FlowSession — #3956) ────────────────
132
195
  // findMany on both models — used by the abandon-email sweep (#3962 D4),
@@ -134,6 +197,10 @@ export const prisma = {
134
197
  // policy) and every candidate FlowSession per definition.
135
198
  flowDefinition: {
136
199
  findUnique: jest.fn(),
200
+ // #6314 — `startProjectListingDraft` resolves the capturing listicle
201
+ // magnet through `findFirst` (slug + siteId + workspaceId + kind), so the
202
+ // delegate needs it here as well as `findUnique`.
203
+ findFirst: jest.fn(),
137
204
  findMany: jest.fn(),
138
205
  upsert: jest.fn(),
139
206
  },
@@ -153,13 +220,21 @@ export const prisma = {
153
220
  },
154
221
  // `update` — the send-outcome stamp (#3998 D1). `findMany` — the
155
222
  // deliverable:resend CLI's list mode (#3998 D4), selecting on
156
- // `sendStatus: null`.
223
+ // `sendStatus: null`. `workspaceId` (#6310 D15) rides `create`'s existing
224
+ // `data` shape — no new delegate method.
157
225
  deliverableGrant: {
158
226
  create: jest.fn(),
159
227
  findUnique: jest.fn(),
160
228
  update: jest.fn(),
161
229
  findMany: jest.fn(),
162
230
  },
231
+ // ── Content namespace (Asset — #5854/#6093, extended #6310 D16) ──────────
232
+ // `findFirst` — the ONLY method `lib/asset-store.ts`'s public read and
233
+ // `lib/deliverable-asset-store.ts`'s two scoped reads need. No write, no
234
+ // `findMany`, no `findUnique` — neither boundary enumerates or mutates.
235
+ asset: {
236
+ findFirst: jest.fn(),
237
+ },
163
238
  // ── Inbox namespace (FlowCheckout — #4837 D4) ─────────────────────────────
164
239
  // `create` — recordCheckout's one write; `update` — attachStripeSession;
165
240
  // `updateMany` — the webhook's guarded status stamp (#4838); `findUnique` /
@@ -179,6 +254,19 @@ export const prisma = {
179
254
  siteListingConfig: {
180
255
  findUnique: jest.fn(),
181
256
  },
257
+ // ── Config namespace (SiteSetting — #6034 § D4) ──────────────────────────
258
+ // `findUnique` — the ONLY read path (`dunningPolicyFor`). #6374 D3 changed
259
+ // its KEY SHAPE, not its arity: `site_settings` is now unique on
260
+ // (workspace_id, key), so the call is
261
+ // `findUnique({ where: { workspaceId_key: { workspaceId, key } } })` and
262
+ // the `key` string still carries the site alias it always did. The
263
+ // delegate itself is unchanged — a `jest.fn()` does not care about the
264
+ // selector shape — but the comment is, so a reader programming this mock
265
+ // writes the compound selector rather than the removed bare `key`. No
266
+ // route in this repo writes SiteSetting; no write delegate is mocked.
267
+ siteSetting: {
268
+ findUnique: jest.fn(),
269
+ },
182
270
  // ── Inbox namespace (ProjectListing — #4998, reshaped #5033) ─────────────
183
271
  // `create` — the POST route's row write (after routeLead's Lead write).
184
272
  // `findFirst` — the tenant-scoped get/patch reads (scoped by siteId + id, a
@@ -254,6 +342,19 @@ export const prisma = {
254
342
  createMany: jest.fn(),
255
343
  findMany: jest.fn(),
256
344
  },
345
+ // ── Inbox namespace (Appointment — #6085, brief H7) ──────────────────────
346
+ // `updateMany` — the § D7(a) guarded write, one per family (lifecycle /
347
+ // payment / no-show); `findFirst` — the disambiguating read on a zero-match
348
+ // updateMany (row absent vs. present-with-newer-watermark); `create` — the
349
+ // create-eligible arm, wrapped in the caller's own `isUniqueViolation`
350
+ // retry. `findMany` — the tenant-scoped GET /v1/schedule/appointments read
351
+ // (apps/api/src/routes/schedule/appointments.ts).
352
+ appointment: {
353
+ create: jest.fn(),
354
+ findFirst: jest.fn(),
355
+ findMany: jest.fn(),
356
+ updateMany: jest.fn(),
357
+ },
257
358
  // ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
258
359
  usageEvent: {
259
360
  create: jest.fn(),
@@ -287,3 +388,141 @@ export const prisma = {
287
388
  $connect: jest.fn(),
288
389
  $disconnect: jest.fn(),
289
390
  };
391
+
392
+
393
+ // ── #6335 — the explicit workspace fixtures ──────────────────────────────
394
+ //
395
+ // These replace the permissive `workspace.findUnique` default. They take the
396
+ // prisma double they are to program as their FIRST argument, rather than
397
+ // reaching for the `prisma` const above: a test importing this file directly
398
+ // gets its own module instance (Jest keeps manual mocks in a registry of
399
+ // their own, separate from the one a plain `require` of this path fills), so
400
+ // a helper that closed over the local `prisma` would program an object no
401
+ // route ever reads. Passing the double the suite already imports from
402
+ // `../lib/prisma.js` is what makes these work from any suite.
403
+
404
+ /**
405
+ * The structural shape the helpers below need — deliberately NOT
406
+ * `typeof prisma`, so a suite can pass the `prisma` it imported from
407
+ * `lib/prisma.js` (typed as the real client) with no cast at the call site.
408
+ */
409
+ type WorkspaceMockHost = { workspace: { findUnique: unknown } };
410
+
411
+ /**
412
+ * The sentinel Organization id the seeded default workspace hangs off
413
+ * (`database/tenancy.ts`'s reserved-range predicate, ADR-0031 rule 7).
414
+ * `resolveSentinelWorkspaceRow` rejects a default-workspace row whose
415
+ * `organizationId` falls outside that range, so the `"default"` alias path
416
+ * resolves only when the mocked row carries this value.
417
+ */
418
+ export const SENTINEL_ORGANIZATION_ID =
419
+ "org_00000000-0000-7000-8000-000000000001";
420
+
421
+ /** The seeded sentinel workspace id — `DEFAULT_WORKSPACE_ID` in `database/tenancy.ts`. */
422
+ export const SENTINEL_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
423
+
424
+ /**
425
+ * The organization an ORDINARY workspace hangs off, and deliberately NOT a
426
+ * sentinel one: its uuid carries non-zero timestamp bits, so
427
+ * `isSentinel()` (`packages/ids`, `^00000000-0000-7000-8000-…`) is false
428
+ * for it.
429
+ *
430
+ * Stamping the sentinel org on every mocked workspace would be its own
431
+ * DEF-014: `organizationId` is read off this exact row as an AUTHORIZATION
432
+ * input — `tenant-context.ts` denies a stale session when it disagrees with
433
+ * the session's active org, and `isPlatformOperator`
434
+ * (`waitlist-operator-reads.ts`) is `isSentinel(organizationId) && role ===
435
+ * "owner"`, which grants cross-SITE read scope over every site's signup
436
+ * PII. A helper that made every workspace sentinel-org would hand a future
437
+ * ordinary-tenant fixture a platform-operator identity, and would make
438
+ * "two workspaces in different organizations" inexpressible — so the
439
+ * stale-session deny branch could never be driven through it.
440
+ */
441
+ export const ORDINARY_ORGANIZATION_ID =
442
+ "org_01920000-0000-7000-8000-0000000000aa";
443
+
444
+ /** One workspace row a suite says exists: an id, or an id bound to a named org. */
445
+ export type MockWorkspaceRow =
446
+ | string
447
+ | { readonly id: string; readonly organizationId: string };
448
+
449
+ function workspaceFindUnique(client: WorkspaceMockHost): jest.Mock {
450
+ const delegate = client.workspace?.findUnique;
451
+ if (typeof delegate !== "function" || !("mockImplementation" in delegate)) {
452
+ throw new Error(
453
+ "[#6335] prisma.workspace.findUnique is not a jest mock — the suite " +
454
+ "must jest.mock() its prisma module before calling this helper.",
455
+ );
456
+ }
457
+ return delegate as unknown as jest.Mock;
458
+ }
459
+
460
+ /**
461
+ * States, for one test or one suite, EXACTLY which workspace ids exist
462
+ * (#6335). Every other id — a foreign one, a spoofed one, an unset one —
463
+ * resolves `null`, so a route that resolves a tenancy scope fails closed
464
+ * unless the test named the id under test.
465
+ *
466
+ * Keyed on the REQUESTED id, and that is the load-bearing part:
467
+ * `resolveWorkspaceIdForAlias` (`apps/api/src/lib/site.ts`) returns the
468
+ * ALIAS on mere truthiness of the row it gets back and never reads that
469
+ * row's own id, so a blanket `mockResolvedValue({ … })` resolves whatever
470
+ * it is handed — permissive by another name, and the rejection cases each
471
+ * suite now carries could not tell the two apart.
472
+ *
473
+ * Call it from a suite's `beforeEach`, AFTER any `jest.clearAllMocks()`.
474
+ */
475
+ export function mockWorkspaceExists(
476
+ client: WorkspaceMockHost,
477
+ ...rows: readonly MockWorkspaceRow[]
478
+ ): void {
479
+ const existing = new Map<string, string>();
480
+ for (const row of rows) {
481
+ if (typeof row === "string") {
482
+ // The sentinel workspace is the ONE row that hangs off the sentinel
483
+ // organization; everything else gets an ordinary one. See
484
+ // ORDINARY_ORGANIZATION_ID for why this distinction is load-bearing.
485
+ existing.set(
486
+ row,
487
+ row === SENTINEL_WORKSPACE_ID
488
+ ? SENTINEL_ORGANIZATION_ID
489
+ : ORDINARY_ORGANIZATION_ID,
490
+ );
491
+ } else {
492
+ existing.set(row.id, row.organizationId);
493
+ }
494
+ }
495
+ workspaceFindUnique(client).mockImplementation(
496
+ ({ where }: { where: { id: string } }) =>
497
+ Promise.resolve(
498
+ existing.has(where.id)
499
+ ? { id: where.id, organizationId: existing.get(where.id) }
500
+ : null,
501
+ ),
502
+ );
503
+ }
504
+
505
+ /**
506
+ * The `"default"` site alias's resolution path (#6335): `currentSiteId()`
507
+ * returns `"default"`, `resolveWorkspaceIdForAlias` hands that to
508
+ * `resolveSentinelWorkspaceRow`, and only the well-known sentinel workspace
509
+ * — hanging off the sentinel Organization — satisfies it. Any further ids
510
+ * passed resolve beside it.
511
+ */
512
+ export function mockSentinelWorkspaceExists(
513
+ client: WorkspaceMockHost,
514
+ ...alsoExisting: readonly MockWorkspaceRow[]
515
+ ): void {
516
+ mockWorkspaceExists(client, SENTINEL_WORKSPACE_ID, ...alsoExisting);
517
+ }
518
+
519
+ /**
520
+ * The REJECTION path (#6335): no workspace resolves, whatever is asked for.
521
+ * Every suite that depends on an explicit `mockWorkspaceExists` carries at
522
+ * least one test using this, so that explicit setup is proven load-bearing
523
+ * rather than decorative — a route that quietly stopped resolving a tenancy
524
+ * scope would otherwise pass both ways.
525
+ */
526
+ export function mockNoWorkspace(client: WorkspaceMockHost): void {
527
+ workspaceFindUnique(client).mockResolvedValue(null);
528
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @file auth-email-budget.test.ts — the per-recipient hourly send budget (#5962 § D8c).
3
+ *
4
+ * Written before the implementation. The five properties that matter:
5
+ * - the bucket counts per (recipient, kind) and caps at three per hour;
6
+ * - the window rolls, so an hour later the recipient is clear again;
7
+ * - the store is bounded (MAX_BUCKETS eviction), because the key is a
8
+ * caller-supplied address and an attacker can mint unlimited distinct ones;
9
+ * - a recipient is keyed by a HASH, never by the plaintext address — this
10
+ * module is memory an operator can dump;
11
+ * - the site threshold is an operator SIGNAL, never a refusal. A site-wide
12
+ * cap an attacker can hold open is an indefinitely-holdable lockout
13
+ * (§ D8c, Q6), so nothing here may deny a send because of it.
14
+ *
15
+ * Time is injected rather than faked: the module reads `getClock().now()` by
16
+ * default and accepts an explicit `nowMs`, which is the seam this suite uses.
17
+ */
18
+
19
+ import {
20
+ consumeRecipientBudget,
21
+ siteVolumeAlert,
22
+ RECIPIENT_HOURLY_MAX,
23
+ SITE_HOURLY_ALERT_THRESHOLD,
24
+ __resetAuthEmailBudget,
25
+ __authEmailBucketCount,
26
+ __authEmailBudgetKeys,
27
+ } from "../auth-email-budget.js";
28
+
29
+ const T0 = 1_700_000_000_000;
30
+ const ONE_HOUR_MS = 60 * 60 * 1000;
31
+
32
+ beforeEach(() => {
33
+ __resetAuthEmailBudget();
34
+ });
35
+
36
+ describe("consumeRecipientBudget()", () => {
37
+ it("allows the first RECIPIENT_HOURLY_MAX sends and refuses the next", () => {
38
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
39
+ expect(
40
+ consumeRecipientBudget("user@example.com", "password-reset", {
41
+ nowMs: T0,
42
+ }),
43
+ ).toBe(true);
44
+ }
45
+ expect(
46
+ consumeRecipientBudget("user@example.com", "password-reset", {
47
+ nowMs: T0,
48
+ }),
49
+ ).toBe(false);
50
+ });
51
+
52
+ it("counts per (recipient, kind) — a reset flood cannot exhaust the notice", () => {
53
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
54
+ consumeRecipientBudget("user@example.com", "password-reset", {
55
+ nowMs: T0,
56
+ });
57
+ }
58
+ expect(
59
+ consumeRecipientBudget("user@example.com", "password-changed", {
60
+ nowMs: T0,
61
+ }),
62
+ ).toBe(true);
63
+ });
64
+
65
+ it("is case- and whitespace-insensitive on the recipient", () => {
66
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
67
+ consumeRecipientBudget("User@Example.com", "password-reset", {
68
+ nowMs: T0,
69
+ });
70
+ }
71
+ expect(
72
+ consumeRecipientBudget(" user@example.com ", "password-reset", {
73
+ nowMs: T0,
74
+ }),
75
+ ).toBe(false);
76
+ });
77
+
78
+ it("clears itself when the hour rolls", () => {
79
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
80
+ consumeRecipientBudget("user@example.com", "password-reset", {
81
+ nowMs: T0,
82
+ });
83
+ }
84
+ expect(
85
+ consumeRecipientBudget("user@example.com", "password-reset", {
86
+ nowMs: T0,
87
+ }),
88
+ ).toBe(false);
89
+ expect(
90
+ consumeRecipientBudget("user@example.com", "password-reset", {
91
+ nowMs: T0 + ONE_HOUR_MS + 1,
92
+ }),
93
+ ).toBe(true);
94
+ });
95
+
96
+ it("bounds the store — a fresh identity at capacity evicts the oldest", () => {
97
+ const cap = 50;
98
+ for (let i = 0; i < cap + 10; i++) {
99
+ consumeRecipientBudget(`user${i}@example.com`, "password-reset", {
100
+ nowMs: T0,
101
+ maxBuckets: cap,
102
+ });
103
+ }
104
+ expect(__authEmailBucketCount()).toBeLessThanOrEqual(cap);
105
+ });
106
+
107
+ it("never stores the plaintext recipient", () => {
108
+ consumeRecipientBudget("secret-person@example.com", "password-reset", {
109
+ nowMs: T0,
110
+ });
111
+ expect(__authEmailBudgetKeys().join("|")).not.toContain("secret-person");
112
+ expect(__authEmailBudgetKeys().join("|")).not.toContain("@example.com");
113
+ });
114
+ });
115
+
116
+ describe("siteVolumeAlert()", () => {
117
+ it("does not fire below the threshold", () => {
118
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD - 1; i++) {
119
+ expect(siteVolumeAlert({ nowMs: T0 })).toBe(false);
120
+ }
121
+ });
122
+
123
+ it("fires ONCE per hour past the threshold, and never refuses a send", () => {
124
+ let fired = 0;
125
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 25; i++) {
126
+ if (siteVolumeAlert({ nowMs: T0 })) fired++;
127
+ }
128
+ expect(fired).toBe(1);
129
+
130
+ // The signal is not a gate: a recipient under their own budget still sends.
131
+ expect(
132
+ consumeRecipientBudget("fresh@example.com", "password-reset", {
133
+ nowMs: T0,
134
+ }),
135
+ ).toBe(true);
136
+
137
+ let firedNextHour = 0;
138
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 1; i++) {
139
+ if (siteVolumeAlert({ nowMs: T0 + ONE_HOUR_MS + 1 })) firedNextHour++;
140
+ }
141
+ expect(firedNextHour).toBe(1);
142
+ });
143
+ });
@@ -5,11 +5,15 @@
5
5
  * (a) CONTENT_ADAPTER unset → MDX adapter (existing behavior preserved)
6
6
  * (b) CONTENT_ADAPTER=projection + valid manifest → projection adapter, read parity vs MDX
7
7
  * (c) CONTENT_ADAPTER=projection + missing manifest → clear actionable error at construction
8
+ * (d) CONTENT_ADAPTER=database with no workspaceId → the resolver's named error
9
+ * (e) CONTENT_ADAPTER=database with a workspaceId → an adapter, not a throw (#5852)
8
10
  */
9
11
  import { join, resolve } from "node:path";
10
12
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
11
13
  import { tmpdir } from "node:os";
12
14
 
15
+ import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
16
+
13
17
  import {
14
18
  getContentAdapter,
15
19
  getContentRoot,
@@ -151,6 +155,25 @@ describe("getContentAdapter() factory", () => {
151
155
  expect(() => getContentAdapter()).toThrow("pnpm content:compile");
152
156
  });
153
157
  });
158
+
159
+ describe("(d) CONTENT_ADAPTER=database → the resolver's workspaceId error", () => {
160
+ test("throws naming workspaceId, because apps/api does not resolve a tenant yet (#5323)", () => {
161
+ process.env.CONTENT_ADAPTER = "database";
162
+
163
+ expect(() => getContentAdapter()).toThrow(/workspaceId/);
164
+ expect(() => getContentAdapter()).toThrow(/CONTENT_ADAPTER=database/);
165
+ });
166
+ });
167
+
168
+ describe("(e) CONTENT_ADAPTER=database + a workspaceId → an adapter (#5852)", () => {
169
+ test("builds the database adapter instead of throwing, once the caller resolves a tenant", () => {
170
+ process.env.CONTENT_ADAPTER = "database";
171
+
172
+ const adapter = getContentAdapter({ workspaceId: DEFAULT_WORKSPACE_ID });
173
+ expect(adapter).toBeDefined();
174
+ expect(typeof adapter.getPageBySlug).toBe("function");
175
+ });
176
+ });
154
177
  });
155
178
 
156
179
  describe("getContentRoot()", () => {