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,686 @@
1
+ /**
2
+ * GET/POST /v1/requests/:id/confirm (#5970) — the pay half of
3
+ * request-and-confirm.
4
+ *
5
+ * Driven through `server.inject` against a Fastify instance that registers the
6
+ * real route group, so the urlencoded parser, the path shapes and the headers
7
+ * are all exercised rather than stubbed. The Stripe package is mocked at the
8
+ * PACKAGE boundary (`@working-theory/stripe-client`, which `lib/stripe.ts`
9
+ * re-exports), and Prisma through the repo-standard `__mocks__/prisma.ts`.
10
+ *
11
+ * "Before any Stripe call" is asserted by MEASURING the doubles rather than by
12
+ * reading the handler: every refusal case ends with both
13
+ * `createAdHocPrice` and `createPaymentLink` at zero calls.
14
+ */
15
+
16
+ import type { FastifyInstance } from "fastify";
17
+ import Fastify from "fastify";
18
+
19
+ const mockCreateAdHocPrice = jest.fn();
20
+ const mockCreatePaymentLink = jest.fn();
21
+ const mockAssertSandboxKey = jest.fn();
22
+ const mockSendRequestPaidLinkEmail = jest.fn();
23
+
24
+ class MockUnknownProductCodeError extends Error {
25
+ constructor(code: string) {
26
+ super(`Unknown product code: ${code}`);
27
+ this.name = "UnknownProductCodeError";
28
+ }
29
+ }
30
+ class MockStripeConfigError extends Error {
31
+ constructor(message: string) {
32
+ super(message);
33
+ this.name = "StripeConfigError";
34
+ }
35
+ }
36
+
37
+ jest.mock("@working-theory/stripe-client", () => ({
38
+ assertSandboxKey: (...args: unknown[]) => mockAssertSandboxKey(...args),
39
+ createAdHocPrice: (...args: unknown[]) => mockCreateAdHocPrice(...args),
40
+ createPaymentLink: (...args: unknown[]) => mockCreatePaymentLink(...args),
41
+ getStripeClient: jest.fn(),
42
+ UnknownProductCodeError: MockUnknownProductCodeError,
43
+ StripeConfigError: MockStripeConfigError,
44
+ STRIPE_API_VERSION: "2024-06-20",
45
+ }));
46
+
47
+ // The licence PACKAGE is the boundary the commerce gate reads, so that is what
48
+ // is doubled — never `lib/require-commerce-claim.js` itself. The gate exercised
49
+ // in the last describe block is therefore the REAL one, resolving a real
50
+ // tri-state off a fake licence, which is the only way that test could notice
51
+ // the gate being removed.
52
+ const mockCheckLicense = jest.fn();
53
+ jest.mock("@working-theory/license", () => ({
54
+ checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
55
+ readStoredJwtState: jest.fn().mockResolvedValue({ state: "absent" }),
56
+ }));
57
+
58
+ jest.mock("@working-theory/email", () => ({
59
+ sendRequestConfirmEmail: jest.fn(),
60
+ sendRequestPaidLinkEmail: (...args: unknown[]) =>
61
+ mockSendRequestPaidLinkEmail(...args),
62
+ }));
63
+
64
+ // 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.
65
+ jest.mock("../../../lib/prisma.js");
66
+
67
+ import { prisma } from "../../../lib/prisma.js";
68
+ import { hashConfirmToken } from "../../../services/request-confirm.js";
69
+ import { requireCommerceClaim } from "../../../lib/require-commerce-claim.js";
70
+ import { requestRoutes } from "../index.js";
71
+
72
+ // A valid prefixed-UUIDv7 shape (ADR-0031) — `confirm.ts` now validates
73
+ // `request.params.id` with `zCallbackId` (#6019 AC-3), and this fixture must
74
+ // pass that gate exactly as a database-generated id would.
75
+ const ID = "callbk_00000000-0000-7000-8000-000000000001";
76
+ const TOKEN = "confirm-token-value";
77
+ const PATH = `/v1/requests/${ID}/confirm`;
78
+ const ROW = {
79
+ id: ID,
80
+ name: "Ada Lovelace",
81
+ phone: "+44 7700 900000",
82
+ preferredTime: "Tuesday morning",
83
+ payload: { email: "buyer@example.com" },
84
+ };
85
+
86
+ const ENV_KEYS = [
87
+ "AILK_REQUEST_CONFIRM_ENABLED",
88
+ "AILK_REQUEST_CONFIRM_TO",
89
+ "AILK_REQUEST_CONFIRM_FROM",
90
+ "AILK_REQUEST_CONFIRM_MAX_MINOR",
91
+ "AILK_PUBLIC_API_URL",
92
+ "STRIPE_SECRET_KEY",
93
+ ] as const;
94
+
95
+ function enable(overrides: Record<string, string | undefined> = {}) {
96
+ process.env["AILK_REQUEST_CONFIRM_ENABLED"] = "true";
97
+ process.env["AILK_REQUEST_CONFIRM_TO"] = "owner@example.com";
98
+ process.env["AILK_REQUEST_CONFIRM_FROM"] = "hello@example.com";
99
+ process.env["AILK_PUBLIC_API_URL"] = "https://api.example.com";
100
+ process.env["STRIPE_SECRET_KEY"] = "sk_test_confirm_route";
101
+ for (const [key, value] of Object.entries(overrides)) {
102
+ if (value === undefined) delete process.env[key];
103
+ else process.env[key] = value;
104
+ }
105
+ }
106
+
107
+ let server: FastifyInstance;
108
+
109
+ beforeEach(async () => {
110
+ jest.clearAllMocks();
111
+ for (const key of ENV_KEYS) delete process.env[key];
112
+ enable();
113
+
114
+ mockAssertSandboxKey.mockImplementation(() => undefined);
115
+ mockCreateAdHocPrice.mockResolvedValue({
116
+ priceId: "price_1",
117
+ lookupKey: `ailk-request-${ID}`,
118
+ });
119
+ mockCreatePaymentLink.mockResolvedValue({
120
+ paymentLinkId: "plink_abc12345",
121
+ url: "https://buy.stripe.com/test_abc",
122
+ });
123
+ mockSendRequestPaidLinkEmail.mockResolvedValue({ ok: true, messageId: "m" });
124
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(ROW);
125
+ (prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 1 });
126
+ (prisma.callback.update as jest.Mock).mockResolvedValue({});
127
+
128
+ server = Fastify({ logger: false });
129
+ await server.register(requestRoutes);
130
+ await server.ready();
131
+ });
132
+
133
+ afterEach(async () => {
134
+ await server.close();
135
+ for (const key of ENV_KEYS) delete process.env[key];
136
+ });
137
+
138
+ function get(query = `?token=${TOKEN}`) {
139
+ return server.inject({ method: "GET", url: `${PATH}${query}` });
140
+ }
141
+
142
+ function post(fields: Record<string, string>) {
143
+ return server.inject({
144
+ method: "POST",
145
+ url: PATH,
146
+ headers: { "content-type": "application/x-www-form-urlencoded" },
147
+ payload: new URLSearchParams(fields).toString(),
148
+ });
149
+ }
150
+
151
+ const VALID_AMOUNT_FORM = {
152
+ token: TOKEN,
153
+ confirmedTime: "Tuesday 9am",
154
+ buyerEmail: "buyer@example.com",
155
+ amountMinor: "12900",
156
+ currency: "usd",
157
+ };
158
+
159
+ function noStripeCalls() {
160
+ expect(mockCreateAdHocPrice).not.toHaveBeenCalled();
161
+ expect(mockCreatePaymentLink).not.toHaveBeenCalled();
162
+ }
163
+
164
+ // ── GET — the form, its headers and its escaping (D7) ──────────────────────
165
+
166
+ describe("GET — the confirm form", () => {
167
+ it("renders the form as html with the five security headers", async () => {
168
+ const res = await get();
169
+ expect(res.statusCode).toBe(200);
170
+ expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
171
+ expect(res.headers["cache-control"]).toBe("no-store");
172
+ // The token is in the query string — this is what keeps it out of a third
173
+ // party's referrer log.
174
+ expect(res.headers["referrer-policy"]).toBe("no-referrer");
175
+ expect(res.headers["x-robots-tag"]).toBe("noindex, nofollow");
176
+ expect(res.headers["content-security-policy"]).toContain(
177
+ "default-src 'none'",
178
+ );
179
+ expect(res.headers["content-security-policy"]).toContain(
180
+ "frame-ancestors 'none'",
181
+ );
182
+ // Neither of these falls back to `default-src` under CSP3.
183
+ expect(res.headers["content-security-policy"]).toContain(
184
+ "form-action 'self'",
185
+ );
186
+ expect(res.headers["content-security-policy"]).toContain("base-uri 'none'");
187
+ expect(res.headers["x-content-type-options"]).toBe("nosniff");
188
+ expect(res.body).toContain("Ada Lovelace");
189
+ expect(res.body).toContain("Confirm this request");
190
+ });
191
+
192
+ it("prefills the buyer email from the captured payload", async () => {
193
+ expect((await get()).body).toContain('value="buyer@example.com"');
194
+ });
195
+
196
+ it("escapes an attacker-supplied name rather than rendering it as markup", async () => {
197
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue({
198
+ ...ROW,
199
+ name: "<script>alert(1)</script>",
200
+ });
201
+ const res = await get();
202
+ expect(res.body).not.toContain("<script>alert(1)</script>");
203
+ expect(res.body).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
204
+ });
205
+
206
+ it("answers one 404 for a token that does not resolve, whatever the reason", async () => {
207
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(null);
208
+ const res = await get("?token=tampered");
209
+ expect(res.statusCode).toBe(404);
210
+ noStripeCalls();
211
+ });
212
+
213
+ it("answers 404 with no token at all", async () => {
214
+ expect((await get("")).statusCode).toBe(404);
215
+ });
216
+
217
+ it("answers 404 when the feature is not configured", async () => {
218
+ delete process.env["AILK_REQUEST_CONFIRM_ENABLED"];
219
+ expect((await get()).statusCode).toBe(404);
220
+ expect(prisma.callback.findFirst).not.toHaveBeenCalled();
221
+ });
222
+
223
+ // #6019 AC-3: `:id` is validated with `zCallbackId` (the sibling routes'
224
+ // idiom) before anything else runs — a malformed id can never resolve a
225
+ // row anyway, so this changes nothing about what a caller learns, only
226
+ // where the request stops.
227
+ it("answers 404 for a malformed id, without ever querying Prisma", async () => {
228
+ const res = await server.inject({
229
+ method: "GET",
230
+ url: `/v1/requests/not-a-callback-id/confirm?token=${TOKEN}`,
231
+ });
232
+ expect(res.statusCode).toBe(404);
233
+ expect(prisma.callback.findFirst).not.toHaveBeenCalled();
234
+ });
235
+ });
236
+
237
+ // ── POST — the happy paths, both of D4's price routes ──────────────────────
238
+
239
+ describe("POST — D4 path 2, an amount priced at confirm time", () => {
240
+ it("stamps, mints ONE Payment Link through an ad-hoc price, and emails the buyer", async () => {
241
+ const res = await post(VALID_AMOUNT_FORM);
242
+ expect(res.statusCode).toBe(200);
243
+
244
+ // The claim carries the stamp AND spends the credential, in one update.
245
+ const claim = (prisma.callback.updateMany as jest.Mock).mock.calls[0][0];
246
+ expect(claim.where.confirmTokenHash).toBe(hashConfirmToken(TOKEN));
247
+ expect(claim.data.confirmedAt).toBeInstanceOf(Date);
248
+ expect(claim.data.confirmedTime).toBe("Tuesday 9am");
249
+ expect(claim.data.confirmTokenHash).toBeNull();
250
+
251
+ expect(mockCreateAdHocPrice).toHaveBeenCalledTimes(1);
252
+ expect(mockCreateAdHocPrice.mock.calls[0][0]).toMatchObject({
253
+ currency: "usd",
254
+ unitAmountMinor: 12900,
255
+ lookupKey: `ailk-request-${ID}`,
256
+ });
257
+ expect(mockCreatePaymentLink).toHaveBeenCalledTimes(1);
258
+ // `completedSessionsLimit: 1` is what stops the emailed link staying
259
+ // permanently payable (#5970 security review round 2, M1).
260
+ expect(mockCreatePaymentLink).toHaveBeenCalledWith({
261
+ lookupKey: `ailk-request-${ID}`,
262
+ completedSessionsLimit: 1,
263
+ });
264
+
265
+ // The artifact id lands on the row — the key the webhook stamp matches on.
266
+ expect(prisma.callback.update).toHaveBeenCalledWith({
267
+ where: { id: ID },
268
+ data: { stripePaymentLinkId: "plink_abc12345" },
269
+ });
270
+
271
+ expect(mockSendRequestPaidLinkEmail).toHaveBeenCalledTimes(1);
272
+ expect(mockSendRequestPaidLinkEmail.mock.calls[0][0]).toMatchObject({
273
+ to: "buyer@example.com",
274
+ paymentUrl: "https://buy.stripe.com/test_abc",
275
+ });
276
+ expect(res.body).toContain("https://buy.stripe.com/test_abc");
277
+ });
278
+ });
279
+
280
+ describe("POST — D4 path 1, a price already in the tenant's catalogue", () => {
281
+ it("passes the lookup key straight to createPaymentLink and mints no price", async () => {
282
+ const res = await post({
283
+ token: TOKEN,
284
+ confirmedTime: "Tuesday 9am",
285
+ buyerEmail: "buyer@example.com",
286
+ lookupKey: "gutter-clean-standard",
287
+ });
288
+ expect(res.statusCode).toBe(200);
289
+ expect(mockCreateAdHocPrice).not.toHaveBeenCalled();
290
+ expect(mockCreatePaymentLink).toHaveBeenCalledWith({
291
+ lookupKey: "gutter-clean-standard",
292
+ completedSessionsLimit: 1,
293
+ });
294
+ // No amount was ever known, so none is stamped and none is emailed.
295
+ const claim = (prisma.callback.updateMany as jest.Mock).mock.calls[0][0];
296
+ expect(claim.data).not.toHaveProperty("amountMinor");
297
+ expect(mockSendRequestPaidLinkEmail.mock.calls[0][0]).not.toHaveProperty(
298
+ "amountMinor",
299
+ );
300
+ });
301
+ });
302
+
303
+ // ── POST — the refusals, every one before any Stripe call ──────────────────
304
+
305
+ describe("POST — refusals before any Stripe call", () => {
306
+ it("refuses a form carrying BOTH a price and a lookup key", async () => {
307
+ const res = await post({ ...VALID_AMOUNT_FORM, lookupKey: "x" });
308
+ expect(res.statusCode).toBe(400);
309
+ noStripeCalls();
310
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
311
+ });
312
+
313
+ it("refuses a form carrying NEITHER", async () => {
314
+ const { amountMinor: _a, currency: _c, ...rest } = VALID_AMOUNT_FORM;
315
+ const res = await post(rest);
316
+ expect(res.statusCode).toBe(400);
317
+ noStripeCalls();
318
+ });
319
+
320
+ it.each([
321
+ ["a zero amount", { amountMinor: "0" }],
322
+ ["a negative amount", { amountMinor: "-100" }],
323
+ ["a fractional amount", { amountMinor: "129.5" }],
324
+ ["a non-numeric amount", { amountMinor: "lots" }],
325
+ // #5970 security review round 3, L3 — a money amount must never be
326
+ // silently reinterpreted from exponent or hex notation.
327
+ ["an exponent-notation amount", { amountMinor: "1e5" }],
328
+ ["a hexadecimal amount", { amountMinor: "0x2710" }],
329
+ ["a padded amount", { amountMinor: " 500 " }],
330
+ ["a malformed currency", { currency: "dollars" }],
331
+ ["an unparseable buyer email", { buyerEmail: "not-an-address" }],
332
+ ["an empty confirmed time", { confirmedTime: "" }],
333
+ ])("refuses %s", async (_label, override) => {
334
+ const res = await post({ ...VALID_AMOUNT_FORM, ...override });
335
+ expect(res.statusCode).toBe(400);
336
+ noStripeCalls();
337
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
338
+ });
339
+
340
+ it("refuses an amount above the configured ceiling", async () => {
341
+ enable({ AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" });
342
+ const res = await post(VALID_AMOUNT_FORM);
343
+ expect(res.statusCode).toBe(400);
344
+ noStripeCalls();
345
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
346
+ });
347
+
348
+ it("refuses a token that does not resolve, and mints nothing", async () => {
349
+ // The guarded claim reports zero rows — a tampered, expired, spent or
350
+ // cross-row token all land here identically.
351
+ (prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
352
+ const res = await post(VALID_AMOUNT_FORM);
353
+ expect(res.statusCode).toBe(404);
354
+ noStripeCalls();
355
+ });
356
+
357
+ it("is single-use: a second POST with the same token is a 404 and mints nothing more", async () => {
358
+ const first = await post(VALID_AMOUNT_FORM);
359
+ expect(first.statusCode).toBe(200);
360
+ (prisma.callback.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
361
+ const second = await post(VALID_AMOUNT_FORM);
362
+ expect(second.statusCode).toBe(404);
363
+ // One Payment Link across both requests.
364
+ expect(mockCreatePaymentLink).toHaveBeenCalledTimes(1);
365
+ });
366
+
367
+ it("refuses a live Stripe key before claiming the credential", async () => {
368
+ mockAssertSandboxKey.mockImplementation(() => {
369
+ throw new MockStripeConfigError("live mode blocked");
370
+ });
371
+ const res = await post(VALID_AMOUNT_FORM);
372
+ expect(res.statusCode).toBe(500);
373
+ noStripeCalls();
374
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
375
+ });
376
+
377
+ it("answers 500 when Stripe is not configured at all", async () => {
378
+ delete process.env["STRIPE_SECRET_KEY"];
379
+ const res = await post(VALID_AMOUNT_FORM);
380
+ expect(res.statusCode).toBe(500);
381
+ noStripeCalls();
382
+ });
383
+
384
+ it("answers 404 when the feature is not configured", async () => {
385
+ delete process.env["AILK_REQUEST_CONFIRM_ENABLED"];
386
+ const res = await post(VALID_AMOUNT_FORM);
387
+ expect(res.statusCode).toBe(404);
388
+ noStripeCalls();
389
+ });
390
+
391
+ // #6019 AC-3 — the same params-first check as the GET handler; a malformed
392
+ // `:id` is refused before the credential preflight touches Prisma.
393
+ it("answers 404 for a malformed id, without ever querying Prisma or Stripe", async () => {
394
+ const res = await server.inject({
395
+ method: "POST",
396
+ url: "/v1/requests/not-a-callback-id/confirm",
397
+ headers: { "content-type": "application/x-www-form-urlencoded" },
398
+ payload: new URLSearchParams(VALID_AMOUNT_FORM).toString(),
399
+ });
400
+ expect(res.statusCode).toBe(404);
401
+ noStripeCalls();
402
+ expect(prisma.callback.findFirst).not.toHaveBeenCalled();
403
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
404
+ });
405
+ });
406
+
407
+ // ── POST — Stripe's own failures ───────────────────────────────────────────
408
+
409
+ describe("POST — Stripe failures", () => {
410
+ it("maps an unknown lookup key to a 400 and creates no link", async () => {
411
+ mockCreatePaymentLink.mockRejectedValue(
412
+ new MockUnknownProductCodeError("nope"),
413
+ );
414
+ const res = await post({
415
+ token: TOKEN,
416
+ confirmedTime: "Tuesday 9am",
417
+ buyerEmail: "buyer@example.com",
418
+ lookupKey: "nope",
419
+ });
420
+ expect(res.statusCode).toBe(400);
421
+ expect(prisma.callback.update).not.toHaveBeenCalled();
422
+ expect(mockSendRequestPaidLinkEmail).not.toHaveBeenCalled();
423
+ });
424
+
425
+ it("answers 502 when the Stripe call fails for any other reason, and says the link is spent", async () => {
426
+ mockCreatePaymentLink.mockRejectedValue(new Error("socket hang up"));
427
+ const res = await post(VALID_AMOUNT_FORM);
428
+ expect(res.statusCode).toBe(502);
429
+ expect(mockSendRequestPaidLinkEmail).not.toHaveBeenCalled();
430
+ // #5970 security review round 3, L2: the page must not promise a retry
431
+ // the system cannot honour — there is no path that mints a fresh link.
432
+ expect(res.body).toContain("now used up");
433
+ expect(res.body).not.toContain("try again from a fresh link");
434
+ });
435
+
436
+ it("still returns the link, with different wording, when the buyer email fails", async () => {
437
+ mockSendRequestPaidLinkEmail.mockResolvedValue({
438
+ ok: false,
439
+ error: { kind: "send_failed", message: "rejected" },
440
+ });
441
+ const res = await post(VALID_AMOUNT_FORM);
442
+ expect(res.statusCode).toBe(200);
443
+ expect(res.body).toContain("https://buy.stripe.com/test_abc");
444
+ expect(res.body).toContain("could not email the customer");
445
+ });
446
+ });
447
+
448
+ // ── The claim-free property (AC-4 / D9) ────────────────────────────────────
449
+
450
+ describe("no licence claim is read by this route's own files (AC-4)", () => {
451
+ it("has no @working-theory/license import in any file this feature added", () => {
452
+ const { readFileSync } = require("node:fs");
453
+ const files = [
454
+ "src/routes/requests/index.ts",
455
+ "src/routes/requests/confirm.ts",
456
+ "src/services/request-confirm.ts",
457
+ ];
458
+ for (const file of files) {
459
+ const source = readFileSync(`${__dirname}/../../../../${file}`, "utf8");
460
+ expect(source).not.toMatch(/from ["']@working-theory\/license["']/);
461
+ expect(source).not.toMatch(/\bcheckLicense\(/);
462
+ expect(source).not.toMatch(/\bresolveCommerceClaim\(/);
463
+ }
464
+ });
465
+ });
466
+
467
+ // ── The credential is checked before anything configuration-dependent ──────
468
+ //
469
+ // #5970 security review, finding 6. Every one of these cases holds NO valid
470
+ // token, and every one must answer the same 404 an unresolvable token gets —
471
+ // otherwise an anonymous caller reads the deployment's money posture off the
472
+ // status codes, and can bisect `amountMinor` to recover the configured
473
+ // ceiling.
474
+ describe("POST — a caller with no credential learns nothing (finding 6)", () => {
475
+ beforeEach(() => {
476
+ // Nothing resolves: this caller holds no live token.
477
+ (prisma.callback.findFirst as jest.Mock).mockResolvedValue(null);
478
+ });
479
+
480
+ it.each([
481
+ [
482
+ "an amount above the ceiling",
483
+ VALID_AMOUNT_FORM,
484
+ { AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" },
485
+ ],
486
+ ["both price paths at once", { ...VALID_AMOUNT_FORM, lookupKey: "x" }, {}],
487
+ ["a malformed body", { token: "nope" }, {}],
488
+ ["a live Stripe key", VALID_AMOUNT_FORM, {}],
489
+ ])("answers the uniform 404 for %s", async (label, form, env) => {
490
+ if (Object.keys(env).length > 0) enable(env);
491
+ if (label === "a live Stripe key") {
492
+ mockAssertSandboxKey.mockImplementation(() => {
493
+ throw new MockStripeConfigError("live mode blocked");
494
+ });
495
+ }
496
+ const res = await post(form as Record<string, string>);
497
+ expect(res.statusCode).toBe(404);
498
+ noStripeCalls();
499
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
500
+ });
501
+
502
+ it("cannot be used to read the configured ceiling by bisection", async () => {
503
+ enable({ AILK_REQUEST_CONFIRM_MAX_MINOR: "10000" });
504
+ const under = await post({ ...VALID_AMOUNT_FORM, amountMinor: "5000" });
505
+ const over = await post({ ...VALID_AMOUNT_FORM, amountMinor: "50000" });
506
+ // Identical: the ceiling is invisible without a credential.
507
+ expect(under.statusCode).toBe(over.statusCode);
508
+ expect(under.statusCode).toBe(404);
509
+ });
510
+ });
511
+
512
+ // ── A typo'd lookup key gives the credential back (finding 4) ──────────────
513
+
514
+ describe("POST — an unknown lookup key does not burn the credential", () => {
515
+ it("restores the digest and clears the stamp so the operator can retry", async () => {
516
+ mockCreatePaymentLink.mockRejectedValue(
517
+ new MockUnknownProductCodeError("typo"),
518
+ );
519
+ const res = await post({
520
+ token: TOKEN,
521
+ confirmedTime: "Tuesday 9am",
522
+ buyerEmail: "buyer@example.com",
523
+ lookupKey: "typo",
524
+ });
525
+ expect(res.statusCode).toBe(400);
526
+
527
+ // Two updateMany calls: the claim, then the release.
528
+ const calls = (prisma.callback.updateMany as jest.Mock).mock.calls;
529
+ expect(calls).toHaveLength(2);
530
+ const release = calls[1][0];
531
+ // `stripePaymentLinkId: null` is what makes "a row that produced nothing"
532
+ // a predicate rather than a claim about the one caller (round 2).
533
+ expect(release.where).toEqual({
534
+ id: ID,
535
+ confirmTokenHash: null,
536
+ stripePaymentLinkId: null,
537
+ paidAt: null,
538
+ });
539
+ // The digest comes back, and the stamp is cleared.
540
+ expect(release.data.confirmTokenHash).toBe(hashConfirmToken(TOKEN));
541
+ expect(release.data.confirmedAt).toBeNull();
542
+ expect(release.data.confirmedTime).toBeNull();
543
+ // The expiry is NOT rewritten — a release cannot extend a credential.
544
+ expect(release.data).not.toHaveProperty("confirmTokenExpiresAt");
545
+ });
546
+
547
+ it("does NOT release on a generic Stripe failure, which may have created a link", async () => {
548
+ mockCreatePaymentLink.mockRejectedValue(new Error("socket hang up"));
549
+ const res = await post(VALID_AMOUNT_FORM);
550
+ expect(res.statusCode).toBe(502);
551
+ expect((prisma.callback.updateMany as jest.Mock).mock.calls).toHaveLength(
552
+ 1,
553
+ );
554
+ });
555
+ });
556
+
557
+ // ── The commerce gate covers BOTH verbs (AC-4 / spec D3) ───────────────────
558
+ //
559
+ // #5970 security review, § 8: every other case in this file registers
560
+ // `requestRoutes` directly, which is the right isolation for the handlers but
561
+ // leaves the GATE untested — and the gate is applied by COMPOSITION in
562
+ // `server.ts` rather than by anything in the route files, which is precisely
563
+ // why no route-level test would notice it disappearing. So this block builds
564
+ // the scope the way `server.ts` builds it and drives both verbs through it.
565
+ describe("the commerce claim gates both verbs (spec D3)", () => {
566
+ const gateLogLines: string[] = [];
567
+
568
+ async function buildGatedServer(): Promise<FastifyInstance> {
569
+ gateLogLines.length = 0;
570
+ const gated = Fastify({
571
+ logger: {
572
+ level: "warn",
573
+ stream: { write: (msg: string) => gateLogLines.push(msg) },
574
+ },
575
+ });
576
+ // The shape of `server.ts`'s own registration: the preHandler is attached
577
+ // to the scope BEFORE the routes are registered into it.
578
+ await gated.register(async (requestsScope) => {
579
+ // The wrapper `server.ts` registers, not the bare gate — so the
580
+ // unclaimed WARN line (spec D3's disclosed residual) is covered too.
581
+ requestsScope.addHook("preHandler", async (request, reply) => {
582
+ await requireCommerceClaim(request, reply);
583
+ if (reply.sent) {
584
+ request.log.warn(
585
+ {
586
+ outcome: "confirm_link_unclaimed",
587
+ path: request.routeOptions.url,
588
+ },
589
+ "request-confirm: a confirm link was opened on a site without the commerce claim",
590
+ );
591
+ }
592
+ });
593
+ await requestsScope.register(requestRoutes);
594
+ });
595
+ await gated.ready();
596
+ return gated;
597
+ }
598
+
599
+ it("answers the module-disabled 404 on GET and POST without the claim, touching nothing", async () => {
600
+ // What `requireCommerceClaim` does on an unclaimed site: short-circuits
601
+ // with the canonical not-found body.
602
+ // A licence that reads successfully and simply lacks the claim — the
603
+ // positively-established "no" the gate answers 404 on.
604
+ delete process.env["AILK_LICENSE_KEY"];
605
+ mockCheckLicense.mockResolvedValue({
606
+ tier: "free",
607
+ plan: "free",
608
+ features: [],
609
+ expiresAt: null,
610
+ revoked: false,
611
+ });
612
+ const gated = await buildGatedServer();
613
+ try {
614
+ const got = await gated.inject({
615
+ method: "GET",
616
+ url: `${PATH}?token=${TOKEN}`,
617
+ });
618
+ expect(got.statusCode).toBe(404);
619
+
620
+ const posted = await gated.inject({
621
+ method: "POST",
622
+ url: PATH,
623
+ headers: { "content-type": "application/x-www-form-urlencoded" },
624
+ payload: new URLSearchParams(VALID_AMOUNT_FORM).toString(),
625
+ });
626
+ expect(posted.statusCode).toBe(404);
627
+
628
+ // The gate ran BEFORE any handler: no row was read, none was stamped,
629
+ // and Stripe was never reached.
630
+ expect(prisma.callback.findFirst).not.toHaveBeenCalled();
631
+ expect(prisma.callback.updateMany).not.toHaveBeenCalled();
632
+ noStripeCalls();
633
+
634
+ // Spec D3 names this line as a bound on its own disclosed residual: a
635
+ // site configured for request-and-confirm but carrying no claim emails a
636
+ // confirm link that 404s. Without the line, the only place that
637
+ // misconfiguration surfaces is the tenant's inbox.
638
+ const warned = gateLogLines.join("\n");
639
+ expect(warned).toContain("confirm_link_unclaimed");
640
+ // Once per refused verb, GET and POST alike.
641
+ expect(warned.split("confirm_link_unclaimed")).toHaveLength(3);
642
+ } finally {
643
+ await gated.close();
644
+ }
645
+ });
646
+
647
+ it("lets the GET through once the claim is present", async () => {
648
+ mockCheckLicense.mockResolvedValue({
649
+ tier: "pro",
650
+ plan: "pro-monthly",
651
+ features: ["commerce"],
652
+ expiresAt: null,
653
+ revoked: false,
654
+ });
655
+ const gated = await buildGatedServer();
656
+ try {
657
+ const got = await gated.inject({
658
+ method: "GET",
659
+ url: `${PATH}?token=${TOKEN}`,
660
+ });
661
+ expect(got.statusCode).toBe(200);
662
+ expect(prisma.callback.findFirst).toHaveBeenCalled();
663
+ } finally {
664
+ await gated.close();
665
+ }
666
+ });
667
+ });
668
+
669
+ // ── A DB fault after Stripe succeeded is reported as itself (L3) ───────────
670
+
671
+ describe("POST — the artifact id fails to record", () => {
672
+ it("still shows the live link, and does not claim the payment will be tracked", async () => {
673
+ // #5970 security review round 2, L3. Reporting this as a Stripe failure
674
+ // told the operator to retry from a fresh link when Stripe HAD created
675
+ // one — a live, chargeable link with no row to stamp.
676
+ (prisma.callback.update as jest.Mock).mockRejectedValue(
677
+ new Error("connection reset"),
678
+ );
679
+ const res = await post(VALID_AMOUNT_FORM);
680
+ expect(res.statusCode).toBe(200);
681
+ expect(res.body).toContain("https://buy.stripe.com/test_abc");
682
+ expect(res.body).toContain("could not record it against this request");
683
+ // And it does NOT tell them to try again from a fresh link.
684
+ expect(res.body).not.toContain("try again from a fresh link");
685
+ });
686
+ });