create-ailk 0.5.0 → 0.6.1

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 (329) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/cli.js +54 -5
  5. package/dist/copy.d.ts +14 -0
  6. package/dist/copy.js +38 -1
  7. package/dist/lib/api-boot-assertion.d.ts +46 -0
  8. package/dist/lib/api-boot-assertion.js +70 -0
  9. package/dist/lib/extract-module-bundle.d.ts +20 -1
  10. package/dist/lib/extract-module-bundle.js +64 -5
  11. package/dist/lib/import-graph.d.ts +29 -0
  12. package/dist/lib/import-graph.js +140 -0
  13. package/dist/lib/module-license-gate.d.ts +6 -1
  14. package/dist/lib/module-license-gate.js +6 -1
  15. package/dist/lib/pro-callout.js +1 -1
  16. package/dist/module-architecture.d.ts +1 -0
  17. package/dist/module-architecture.js +253 -127
  18. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  19. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  20. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  21. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  22. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  23. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  24. package/dist/programmatic.js +14 -10
  25. package/dist/surfaces.js +105 -7
  26. package/dist/template-manifest.d.ts +33 -0
  27. package/dist/template-manifest.js +99 -16
  28. package/dist/template-names.d.ts +1 -1
  29. package/dist/template-names.js +1 -0
  30. package/package.json +3 -2
  31. package/site-templates/marketing-site/DESIGN.md +148 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  34. package/site-templates/marketing-site/ailk-template.json +1 -1
  35. package/site-templates/marketing-site-free/DESIGN.md +148 -0
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  38. package/site-templates/minimalism-site/DESIGN.md +148 -0
  39. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  41. package/site-templates/minimalism-site/ailk-template.json +2 -1
  42. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  43. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  44. package/site-templates/saas-product/DESIGN.md +148 -0
  45. package/site-templates/simple-service/DESIGN.md +148 -0
  46. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  47. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  48. package/site-templates/simple-service/ailk-template.json +10 -0
  49. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  50. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  51. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  52. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  53. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  54. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  55. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  56. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  57. package/site-templates/simple-service/provenance.json +4 -0
  58. package/templates/.env.example +72 -7
  59. package/templates/CLAUDE.md +2 -3
  60. package/templates/DESIGN.md +155 -0
  61. package/templates/README.md +42 -8
  62. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  63. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  64. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  65. package/templates/apps/api/.env.example +13 -0
  66. package/templates/apps/api/CLAUDE.md +26 -2
  67. package/templates/apps/api/jest.config.cjs +3 -0
  68. package/templates/apps/api/package.json +1 -0
  69. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  70. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  71. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  72. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  73. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  74. package/templates/apps/api/src/config/index.ts +62 -0
  75. package/templates/apps/api/src/config/modules.ts +7 -3
  76. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  77. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  78. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  79. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  81. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  82. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  83. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  84. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  85. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  86. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  87. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  88. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  89. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  90. package/templates/apps/api/src/lib/site.ts +208 -9
  91. package/templates/apps/api/src/lib/stripe.ts +66 -0
  92. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  93. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  94. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  95. package/templates/apps/api/src/openapi/spec.ts +543 -2
  96. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  97. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  98. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  99. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  100. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  101. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  102. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  103. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  104. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  105. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  106. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  107. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  108. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  109. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  110. package/templates/apps/api/src/routes/content/index.ts +13 -73
  111. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  112. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  113. package/templates/apps/api/src/routes/content/search.ts +129 -0
  114. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  115. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  116. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  120. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  121. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  122. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  123. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  124. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  125. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  126. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  127. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  128. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  129. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  130. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  131. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  132. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  133. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  134. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  135. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  136. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  137. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  138. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  139. package/templates/apps/api/src/server.ts +439 -49
  140. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  141. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  142. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  143. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  144. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  145. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  146. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  147. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  148. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  149. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  150. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  151. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  152. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  153. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  154. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  155. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  156. package/templates/apps/api/src/services/audit-log.ts +668 -0
  157. package/templates/apps/api/src/services/consent.ts +18 -0
  158. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  159. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  160. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  161. package/templates/apps/api/src/services/entitlements.ts +80 -0
  162. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  163. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  164. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  165. package/templates/apps/api/src/services/listing-config.ts +27 -9
  166. package/templates/apps/api/src/services/project-listings.ts +201 -6
  167. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  168. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  169. package/templates/apps/api/src/services/revalidation.ts +173 -0
  170. package/templates/apps/api/src/services/verified-email.ts +71 -0
  171. package/templates/apps/api/src/vercel-handler.ts +75 -35
  172. package/templates/apps/mcp/.env.example +17 -0
  173. package/templates/apps/mcp/CLAUDE.md +57 -24
  174. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  175. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  176. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  177. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  178. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  179. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  180. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  181. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  182. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  183. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  184. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  185. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  186. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  187. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  188. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  189. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  190. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  191. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  192. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  193. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  194. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  195. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  196. package/templates/apps/mcp/jest.config.cjs +13 -0
  197. package/templates/apps/mcp/package.json +2 -0
  198. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  199. package/templates/apps/mcp/src/config/modules.ts +10 -5
  200. package/templates/apps/mcp/src/http-transport.ts +102 -29
  201. package/templates/apps/mcp/src/index.ts +73 -11
  202. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  203. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  204. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  205. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  206. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  207. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  208. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  209. package/templates/apps/mcp/src/server.ts +59 -35
  210. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  211. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  212. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  213. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  214. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  215. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  216. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  217. package/templates/apps/mcp/src/tools/index.ts +6 -4
  218. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  219. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  220. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  221. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  222. package/templates/apps/web/.env.example +71 -5
  223. package/templates/apps/web/CLAUDE.md +44 -0
  224. package/templates/apps/web/PRODUCT.md +86 -0
  225. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  226. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  227. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  228. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  229. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  230. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  231. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  232. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  233. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  234. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  235. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  236. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  237. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  238. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  239. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  240. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  241. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  242. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  243. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  244. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  245. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  246. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  247. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  248. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  249. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  250. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  251. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  252. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  253. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  254. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  255. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  256. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  257. package/templates/apps/web/auth.ts +407 -8
  258. package/templates/apps/web/jest.config.cjs +8 -4
  259. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  260. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  261. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  262. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  263. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  264. package/templates/apps/web/lib/auth-email.ts +318 -0
  265. package/templates/apps/web/lib/auth-env.ts +85 -0
  266. package/templates/apps/web/lib/auth-routes.ts +55 -0
  267. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  268. package/templates/apps/web/lib/site-brand.tsx +4 -8
  269. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  270. package/templates/apps/web/messages/en.json +187 -0
  271. package/templates/apps/web/middleware.ts +1 -1
  272. package/templates/apps/web/package.json +2 -1
  273. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  274. package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
  275. package/templates/content/_site.mdx +18 -3
  276. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  277. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  278. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  279. package/templates/database/CHANGELOG.md +149 -0
  280. package/templates/database/README.md +10 -3
  281. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  282. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  283. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  284. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  285. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  286. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  287. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  288. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  289. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  290. package/templates/database/__tests__/tenancy.test.ts +141 -0
  291. package/templates/database/config/schema.prisma +79 -2
  292. package/templates/database/content/schema.prisma +87 -11
  293. package/templates/database/inbox/schema.prisma +452 -9
  294. package/templates/database/jest.config.cjs +5 -3
  295. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  296. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  297. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  298. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  299. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  300. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  301. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  302. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  303. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  304. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  305. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  306. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  307. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  308. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  309. package/templates/database/package.json +3 -2
  310. package/templates/database/tenancy.ts +86 -1
  311. package/templates/database/tsconfig.json +1 -0
  312. package/versions.json +34 -34
  313. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  314. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  315. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  316. package/templates/apps/api/src/routes/content/create.ts +0 -115
  317. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  318. package/templates/apps/api/src/routes/content/update.ts +0 -112
  319. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  320. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  321. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  322. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  323. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  324. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  325. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  326. package/templates/apps/web/public/logo-dark.svg +0 -21
  327. package/templates/apps/web/public/logo-light.svg +0 -21
  328. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  329. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,515 @@
1
+ /**
2
+ * @file password-reset.test.tsx — AC-2's scorer (#5962).
3
+ *
4
+ * The three pages' branch matrix, rendered against a mocked `auth-env` — the
5
+ * idiom the sibling `auth-pages.test.tsx` and `sign-in/__tests__/page.test.tsx`
6
+ * already use.
7
+ *
8
+ * "Never a 404" is STRUCTURAL rather than asserted here: the three segments
9
+ * exist as files, so Next resolves them whatever the predicate says. What this
10
+ * suite asserts is that each resolves to a real card in every branch.
11
+ *
12
+ * The seventh case is the one a page-render assertion would miss entirely:
13
+ * `forgot-password-card` must pass a NON-EMPTY, ABSOLUTE `redirectTo`. Better
14
+ * Auth rejects an empty `callbackURL` on the token route, so without it every
15
+ * emitted reset link dead-ends at an error page while the page itself looks
16
+ * perfectly correct.
17
+ */
18
+ import { render, screen, waitFor } from "@testing-library/react";
19
+ import userEvent from "@testing-library/user-event";
20
+
21
+ const mockIsResetAvailable = jest.fn();
22
+ const mockResetSendConfigured = jest.fn();
23
+ const mockGetActiveProviderIds = jest.fn();
24
+ const mockIsSignUpOpen = jest.fn();
25
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
26
+ jest.mock("../../../../lib/auth-env", () => ({
27
+ __esModule: true,
28
+ getActiveProviderIds: () => mockGetActiveProviderIds(),
29
+ isSignUpOpen: () => mockIsSignUpOpen(),
30
+ isResetAvailable: () => mockIsResetAvailable(),
31
+ resetSendConfigured: () => mockResetSendConfigured(),
32
+ }));
33
+
34
+ const mockRequestPasswordReset = jest.fn();
35
+ const mockResetPassword = jest.fn();
36
+ jest.mock("better-auth/react", () => ({
37
+ __esModule: true,
38
+ createAuthClient: () => ({
39
+ requestPasswordReset: (...args: unknown[]) =>
40
+ mockRequestPasswordReset(...args),
41
+ resetPassword: (...args: unknown[]) => mockResetPassword(...args),
42
+ signIn: { social: jest.fn(), email: jest.fn() },
43
+ }),
44
+ }));
45
+
46
+ /**
47
+ * #6068 AC-1 exercises `createAuth()`'s `onPasswordReset` callback, so the
48
+ * three Better Auth entry points it touches are stubbed the way the sibling
49
+ * `apps/web/__tests__/auth/password-reset-config.test.ts` stubs them — the
50
+ * real ESM-only module never loads under the Jest CJS preset, and `betterAuth`
51
+ * returning `{ options }` is what makes the wired callback reachable at all.
52
+ */
53
+ jest.mock("better-auth", () => ({
54
+ betterAuth: (options: unknown) => ({ options }),
55
+ }));
56
+
57
+ jest.mock("better-auth/adapters/prisma", () => ({
58
+ prismaAdapter: () => ({ id: "mock-prisma-adapter" }),
59
+ }));
60
+
61
+ jest.mock("better-auth/next-js", () => ({
62
+ nextCookies: () => ({ id: "next-cookies" }),
63
+ }));
64
+
65
+ /**
66
+ * The signer seam. `buildAuthEmailRequest` has exactly one external
67
+ * dependency — `node:crypto`'s `createHmac` — so a throwing `createHmac` is a
68
+ * throwing `buildAuthEmailRequest` reached through the real call path, with no
69
+ * stub standing in for the function under test. Every other member is the real
70
+ * one, and the default implementation below is the real `createHmac`, so only
71
+ * the case that opts in sees a failure.
72
+ */
73
+ const mockCreateHmac = jest.fn();
74
+ jest.mock("node:crypto", () => {
75
+ const actual = jest.requireActual("node:crypto");
76
+ return {
77
+ ...actual,
78
+ createHmac: (...args: unknown[]) => mockCreateHmac(...args),
79
+ };
80
+ });
81
+
82
+ import en from "../../../../messages/en.json";
83
+ import { createAuth } from "../../../../auth";
84
+ import ForgotPasswordPage from "../forgot-password/page";
85
+ import ResetPasswordPage from "../reset-password/page";
86
+ import { ResetPasswordCard } from "../reset-password/reset-password-card";
87
+ import SignInPage from "../sign-in/page";
88
+ import VerifyEmailPage from "../verify-email/page";
89
+
90
+ const ORIGINAL_ENV = process.env;
91
+
92
+ /** The slice of Better Auth's options object #6068 AC-1 reads. */
93
+ type AuthOptionsUnderTest = {
94
+ emailAndPassword?: {
95
+ onPasswordReset?: (args: {
96
+ user: { id: string; email: string };
97
+ }) => Promise<void>;
98
+ };
99
+ };
100
+
101
+ /**
102
+ * `NonNullable` on BOTH levels, exactly as the sibling config suite does it:
103
+ * the inner one strips the `| undefined` off the optional `prisma` member,
104
+ * without which `exactOptionalPropertyTypes: true` rejects the assignment.
105
+ */
106
+ type AuthPrisma = NonNullable<
107
+ NonNullable<Parameters<typeof createAuth>[0]>["prisma"]
108
+ >;
109
+
110
+ async function renderForgot(): Promise<void> {
111
+ render(await ForgotPasswordPage({ params: Promise.resolve({ locale: "en" }) }));
112
+ }
113
+
114
+ async function renderReset(
115
+ query: Record<string, string> = {},
116
+ ): Promise<void> {
117
+ render(
118
+ await ResetPasswordPage({
119
+ params: Promise.resolve({ locale: "en" }),
120
+ searchParams: Promise.resolve(query),
121
+ }),
122
+ );
123
+ }
124
+
125
+ async function renderVerify(
126
+ query: Record<string, string> = {},
127
+ ): Promise<void> {
128
+ render(
129
+ await VerifyEmailPage({
130
+ params: Promise.resolve({ locale: "en" }),
131
+ searchParams: Promise.resolve(query),
132
+ }),
133
+ );
134
+ }
135
+
136
+ beforeEach(() => {
137
+ process.env = { ...ORIGINAL_ENV, BETTER_AUTH_URL: "https://site.example" };
138
+ jest.clearAllMocks();
139
+ mockIsResetAvailable.mockReturnValue(true);
140
+ mockResetSendConfigured.mockReturnValue(true);
141
+ mockGetActiveProviderIds.mockReturnValue(["email"]);
142
+ mockIsSignUpOpen.mockReturnValue(false);
143
+ mockRequestPasswordReset.mockResolvedValue({ data: {}, error: null });
144
+ mockResetPassword.mockResolvedValue({ data: {}, error: null });
145
+ // The real signer by default — only the #6068 case below makes it throw.
146
+ mockCreateHmac.mockImplementation((...args: unknown[]) =>
147
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- forwarding an untyped rest to the real overloaded builtin
148
+ (jest.requireActual("node:crypto") as any).createHmac(...args),
149
+ );
150
+ });
151
+
152
+ afterAll(() => {
153
+ process.env = ORIGINAL_ENV;
154
+ });
155
+
156
+ // ── The six states AC-2 names ───────────────────────────────────────────────
157
+
158
+ describe("/forgot-password", () => {
159
+ it("renders the forgot form when reset is available", async () => {
160
+ await renderForgot();
161
+ expect(screen.getByTestId("auth-email")).toBeInTheDocument();
162
+ });
163
+
164
+ it("renders the not-available state — never a 404 — when it is not", async () => {
165
+ mockResetSendConfigured.mockReturnValue(false);
166
+ await renderForgot();
167
+ expect(screen.getByTestId("forgot-password-page")).toBeInTheDocument();
168
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
169
+ en.auth.forgotUnavailableBody,
170
+ );
171
+ expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
172
+ });
173
+ });
174
+
175
+ describe("/reset-password", () => {
176
+ it("renders the password fields when it carries a token", async () => {
177
+ await renderReset({ token: "tok_live" });
178
+ expect(screen.getByTestId("auth-password")).toBeInTheDocument();
179
+ expect(screen.getByTestId("auth-confirm-password")).toBeInTheDocument();
180
+ });
181
+
182
+ it("renders the expired-token state — and NO password field — on ?error", async () => {
183
+ await renderReset({ error: "INVALID_TOKEN" });
184
+ expect(screen.getByTestId("reset-token-error")).toBeInTheDocument();
185
+ expect(
186
+ screen.getByRole("heading", { name: en.auth.resetExpiredHeading }),
187
+ ).toBeInTheDocument();
188
+ expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
189
+ });
190
+
191
+ it("renders its own not-available copy when reset is off", async () => {
192
+ mockResetSendConfigured.mockReturnValue(false);
193
+ await renderReset({ token: "tok_live" });
194
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
195
+ en.auth.resetUnavailableBody,
196
+ );
197
+ expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
198
+ });
199
+ });
200
+
201
+ describe("/verify-email", () => {
202
+ it("renders the sent terminus with no ?error", async () => {
203
+ await renderVerify();
204
+ expect(screen.getByTestId("auth-sent-body")).toBeInTheDocument();
205
+ });
206
+
207
+ it("renders the expired-token state on ?error", async () => {
208
+ await renderVerify({ error: "TOKEN_EXPIRED" });
209
+ expect(screen.getByTestId("verify-token-error")).toBeInTheDocument();
210
+ expect(screen.queryByTestId("auth-sent-body")).not.toBeInTheDocument();
211
+ });
212
+ });
213
+
214
+ // ── The flag-alone configuration: intent set, send impossible ──────────────
215
+
216
+ describe("the flag alone does not open either page", () => {
217
+ /**
218
+ * `AILK_AUTH_PASSWORD_RESET=1` with a send conjunct missing is a real
219
+ * deployment state, and it is the one a flag-only predicate reads as
220
+ * "available". On the request page that renders a live form whose only
221
+ * possible outcome is the error card; on the consume page it renders a live
222
+ * password field over a POST Better Auth still serves. Both pages key on
223
+ * `resetSendConfigured()` so neither can be reached in that state.
224
+ */
225
+ beforeEach(() => {
226
+ mockIsResetAvailable.mockReturnValue(true);
227
+ mockResetSendConfigured.mockReturnValue(false);
228
+ });
229
+
230
+ it("/forgot-password renders not-available, not the form", async () => {
231
+ await renderForgot();
232
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
233
+ en.auth.forgotUnavailableBody,
234
+ );
235
+ expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
236
+ });
237
+
238
+ it("/reset-password renders not-available even carrying a live token", async () => {
239
+ await renderReset({ token: "tok_live" });
240
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
241
+ en.auth.resetUnavailableBody,
242
+ );
243
+ expect(screen.queryByTestId("auth-password")).not.toBeInTheDocument();
244
+ });
245
+ });
246
+
247
+ // ── The seventh case: the redirectTo a render assertion cannot see ──────────
248
+
249
+ describe("the forgot island's requestPasswordReset call", () => {
250
+ it("passes a non-empty, ABSOLUTE, same-origin redirectTo", async () => {
251
+ const user = userEvent.setup();
252
+ await renderForgot();
253
+
254
+ await user.type(screen.getByTestId("auth-email"), "person@example.com");
255
+ await user.click(screen.getByTestId("auth-submit"));
256
+
257
+ await waitFor(() => expect(mockRequestPasswordReset).toHaveBeenCalled());
258
+ const [args] = mockRequestPasswordReset.mock.calls[0];
259
+ expect(args.email).toBe("person@example.com");
260
+ expect(args.redirectTo).toBe("https://site.example/reset-password");
261
+ // Absolute: originCheck compares an ORIGIN, and an empty callbackURL makes
262
+ // Better Auth reject the emitted link outright.
263
+ expect(args.redirectTo.startsWith("https://")).toBe(true);
264
+ });
265
+
266
+ it("swaps to the sent intent only on success", async () => {
267
+ const user = userEvent.setup();
268
+ await renderForgot();
269
+
270
+ await user.type(screen.getByTestId("auth-email"), "person@example.com");
271
+ await user.click(screen.getByTestId("auth-submit"));
272
+
273
+ await waitFor(() =>
274
+ expect(screen.getByTestId("auth-sent-body")).toBeInTheDocument(),
275
+ );
276
+ });
277
+
278
+ it("shows the error state — never 'check your email' — when the server refuses", async () => {
279
+ mockRequestPasswordReset.mockResolvedValue({
280
+ error: { code: "RESET_PASSWORD_DISABLED" },
281
+ });
282
+ const user = userEvent.setup();
283
+ await renderForgot();
284
+
285
+ await user.type(screen.getByTestId("auth-email"), "person@example.com");
286
+ await user.click(screen.getByTestId("auth-submit"));
287
+
288
+ await waitFor(() =>
289
+ expect(screen.getByText(en.auth.resetRequestFailed)).toBeInTheDocument(),
290
+ );
291
+ // The whole point: a site that cannot send never claims it did.
292
+ expect(screen.queryByTestId("auth-sent-body")).not.toBeInTheDocument();
293
+ });
294
+ });
295
+
296
+ describe("the reset island's resetPassword call", () => {
297
+ it("sends the token in the body and routes to sign-in on success", async () => {
298
+ // The island directly, so the post-reset navigation is observable —
299
+ // jsdom's `window.location` is read-only, which is why the component takes
300
+ // an injectable `navigate` at all.
301
+ const navigate = jest.fn();
302
+ const user = userEvent.setup();
303
+ render(
304
+ <ResetPasswordCard
305
+ token="tok_live"
306
+ signInHref="/sign-in"
307
+ labels={{
308
+ resetHeading: en.auth.resetHeading,
309
+ resetSubmit: en.auth.resetSubmit,
310
+ newPassword: en.auth.newPassword,
311
+ confirmPassword: en.auth.confirmPassword,
312
+ backToSignIn: en.auth.backToSignIn,
313
+ }}
314
+ errorLabels={{
315
+ resetFailed: en.auth.resetFailed,
316
+ invalidToken: en.auth.resetExpiredSubhead,
317
+ mismatch: en.auth.passwordMismatch,
318
+ }}
319
+ navigate={navigate}
320
+ />,
321
+ );
322
+
323
+ await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
324
+ await user.type(
325
+ screen.getByTestId("auth-confirm-password"),
326
+ "a-long-enough-pw",
327
+ );
328
+ await user.click(screen.getByTestId("auth-submit"));
329
+
330
+ await waitFor(() => expect(mockResetPassword).toHaveBeenCalled());
331
+ expect(mockResetPassword.mock.calls[0][0]).toEqual({
332
+ newPassword: "a-long-enough-pw",
333
+ token: "tok_live",
334
+ });
335
+ await waitFor(() => expect(navigate).toHaveBeenCalledWith("/sign-in"));
336
+ });
337
+
338
+ it("refuses mismatched passwords without calling the server", async () => {
339
+ const user = userEvent.setup();
340
+ await renderReset({ token: "tok_live" });
341
+
342
+ await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
343
+ await user.type(screen.getByTestId("auth-confirm-password"), "different-pw");
344
+ await user.click(screen.getByTestId("auth-submit"));
345
+
346
+ await waitFor(() =>
347
+ expect(screen.getByText(en.auth.passwordMismatch)).toBeInTheDocument(),
348
+ );
349
+ expect(mockResetPassword).not.toHaveBeenCalled();
350
+ });
351
+
352
+ it("treats an absent token as an expired one, and does not call the server", async () => {
353
+ const user = userEvent.setup();
354
+ await renderReset({});
355
+
356
+ await user.type(screen.getByTestId("auth-password"), "a-long-enough-pw");
357
+ await user.type(
358
+ screen.getByTestId("auth-confirm-password"),
359
+ "a-long-enough-pw",
360
+ );
361
+ await user.click(screen.getByTestId("auth-submit"));
362
+
363
+ await waitFor(() =>
364
+ expect(
365
+ screen.getByText(en.auth.resetExpiredSubhead),
366
+ ).toBeInTheDocument(),
367
+ );
368
+ expect(mockResetPassword).not.toHaveBeenCalled();
369
+ });
370
+ });
371
+
372
+ // ── #6068 AC-1: a throwing signer cannot reject onPasswordReset ─────────────
373
+
374
+ /**
375
+ * The reset has ALREADY SUCCEEDED by the time `onPasswordReset` runs: the new
376
+ * password is written and the presented token is consumed. A rejection here is
377
+ * therefore not "the reset failed" — it is a 500 handed to a visitor whose
378
+ * password DID change, over a token they can no longer retry with.
379
+ *
380
+ * `sendPasswordChangedNotice` carries a "never throws by construction"
381
+ * invariant, and before #6068 that invariant had a hole: `dispatch()` built
382
+ * and SIGNED the request OUTSIDE its own try, so anything `buildAuthEmailRequest`
383
+ * threw propagated all the way out. #6061 made this callback unconditional, so
384
+ * it now runs in degraded configurations — exactly where a signer is most
385
+ * likely to be unhappy.
386
+ *
387
+ * The signer's one external dependency is `node:crypto`'s `createHmac`, so a
388
+ * throwing `createHmac` IS a throwing `buildAuthEmailRequest`, reached through
389
+ * the real call path rather than a stubbed-out seam.
390
+ */
391
+ describe("onPasswordReset survives a throwing buildAuthEmailRequest (#6068)", () => {
392
+ const user = { id: "usr_reset", email: "person@example.com" };
393
+
394
+ function onPasswordResetUnderTest(deleteMany: jest.Mock): (args: {
395
+ user: { id: string; email: string };
396
+ }) => Promise<void> {
397
+ // `unknown` on the way in, exactly as the sibling config suite does it, so
398
+ // the stub needs ONE assertion rather than a double one.
399
+ const prismaStub: unknown = { verification: { deleteMany } };
400
+ const auth = createAuth({ prisma: prismaStub as AuthPrisma });
401
+ // The `betterAuth` mock above returns `{ options }` rather than a real
402
+ // instance, so the declared return type is not the runtime shape. There is
403
+ // no boundary type to fix: the double assertion IS the mock seam — the
404
+ // same seam, and the same justification, as the sibling config suite.
405
+ // eslint-disable-next-line no-restricted-syntax -- see above.
406
+ const { options } = auth as unknown as { options: AuthOptionsUnderTest };
407
+ const callback = options.emailAndPassword?.onPasswordReset;
408
+ // Guarded rather than asserted inline: without it the cases below would
409
+ // read `undefined(...)` and fail with a TypeError instead of a diagnosis.
410
+ if (!callback) throw new Error("onPasswordReset was not wired");
411
+ return callback;
412
+ }
413
+
414
+ beforeEach(() => {
415
+ // Past `dispatch()`'s `not_configured` early return — without a secret the
416
+ // signer is never reached and the case would pass vacuously.
417
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "whsec_test";
418
+ process.env.AUTH_EMAIL_FROM = "noreply@site.example";
419
+ mockCreateHmac.mockImplementation(() => {
420
+ throw new Error("signer unavailable");
421
+ });
422
+ });
423
+
424
+ it("resolves rather than rejecting — the completed reset is not turned into a 500", async () => {
425
+ const deleteMany = jest.fn().mockResolvedValue({ count: 1 });
426
+ await expect(
427
+ onPasswordResetUnderTest(deleteMany)({ user }),
428
+ ).resolves.toBeUndefined();
429
+ });
430
+
431
+ it("still consumes the user's other outstanding reset tokens", async () => {
432
+ const deleteMany = jest.fn().mockResolvedValue({ count: 2 });
433
+ await onPasswordResetUnderTest(deleteMany)({ user });
434
+ // The sibling invalidation runs BEFORE the notice, so a notice that threw
435
+ // would leave it done-but-unreported; what it may never do is leave the
436
+ // caller with a rejected promise over work that already landed.
437
+ expect(deleteMany).toHaveBeenCalledWith({
438
+ where: {
439
+ value: user.id,
440
+ identifier: { startsWith: "reset-password:" },
441
+ },
442
+ });
443
+ });
444
+ });
445
+
446
+ // ── #6068 AC-2: the flag-alone config, read through the REAL predicate ──────
447
+
448
+ /**
449
+ * The sibling "the flag alone does not open either page" block above drives
450
+ * the two reset pages through the MOCKED predicate — it asserts the pages
451
+ * branch correctly once `resetSendConfigured()` has already answered false.
452
+ * This block asserts the answer itself, and the third affordance: with
453
+ * `AILK_AUTH_PASSWORD_RESET=1` and `AUTH_EMAIL_WEBHOOK_SECRET` unset — intent
454
+ * set, transport missing, every other conjunct satisfied — the REAL
455
+ * `resetSendConfigured()` is what decides, and `/sign-in` must render no
456
+ * "Forgot password?" link while `/forgot-password` renders its not-available
457
+ * card. A flag-keyed link in this state is the dangling link #6018 shipped to
458
+ * prevent and #6061 left behind on this one surface.
459
+ *
460
+ * The module mock stays (the two pages share it with the whole file); its
461
+ * members are re-bound here to `jest.requireActual`'s real implementations, so
462
+ * the env vars below — not a `mockReturnValue` — are what the pages read.
463
+ */
464
+ describe("the flag alone hides the /sign-in forgot link (#6068 AC-2)", () => {
465
+ const realAuthEnv = jest.requireActual<
466
+ typeof import("../../../../lib/auth-env")
467
+ >("../../../../lib/auth-env");
468
+
469
+ beforeEach(() => {
470
+ mockIsResetAvailable.mockImplementation(() => realAuthEnv.isResetAvailable());
471
+ mockResetSendConfigured.mockImplementation(() =>
472
+ realAuthEnv.resetSendConfigured(),
473
+ );
474
+ mockGetActiveProviderIds.mockImplementation(() =>
475
+ realAuthEnv.getActiveProviderIds(),
476
+ );
477
+
478
+ // Every conjunct of `resetSendConfigured()` satisfied EXCEPT the send
479
+ // secret: the operator meant to offer reset and the relay cannot sign.
480
+ process.env.AILK_AUTH_PASSWORD_RESET = "1";
481
+ process.env.AUTH_EMAIL_SERVER = "smtp://mail.example";
482
+ process.env.AUTH_EMAIL_FROM = "noreply@site.example";
483
+ process.env.BETTER_AUTH_URL = "https://site.example";
484
+ process.env.BETTER_AUTH_SECRET = "test-secret";
485
+ delete process.env.AUTH_EMAIL_WEBHOOK_SECRET;
486
+ });
487
+
488
+ it("is a state the two predicates genuinely disagree about", () => {
489
+ // Without this the two cases below could pass for the wrong reason — a
490
+ // config neither predicate calls available proves nothing about which one
491
+ // an affordance keys on.
492
+ expect(realAuthEnv.isResetAvailable()).toBe(true);
493
+ expect(realAuthEnv.resetSendConfigured()).toBe(false);
494
+ });
495
+
496
+ it("/sign-in renders the password form and NO forgot link", async () => {
497
+ render(
498
+ await SignInPage({
499
+ params: Promise.resolve({ locale: "en" }),
500
+ searchParams: Promise.resolve({}),
501
+ }),
502
+ );
503
+ // The card itself is live — this is a hidden affordance, not a dead page.
504
+ expect(screen.getByTestId("auth-password")).toBeInTheDocument();
505
+ expect(screen.queryByTestId("auth-forgot")).toBeNull();
506
+ });
507
+
508
+ it("/forgot-password renders the not-available card", async () => {
509
+ await renderForgot();
510
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
511
+ en.auth.forgotUnavailableBody,
512
+ );
513
+ expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
514
+ });
515
+ });
@@ -0,0 +1,89 @@
1
+ "use client"; // Better Auth's client call (requestPasswordReset) + the sent/error state
2
+
3
+ /**
4
+ * ForgotPasswordCard — `/forgot-password`'s client island (#5962).
5
+ *
6
+ * #5963 shipped this page as a static `forgot` intent with no `onSubmit`, so
7
+ * the form was a deliberate no-op. This island is what makes it work.
8
+ *
9
+ * `redirectTo` IS MANDATORY, and that is not a detail. Better Auth builds the
10
+ * emailed link's `callbackURL` from it, and `/reset-password/:token` REJECTS an
11
+ * empty `callbackURL` outright — so without it every emitted reset link would
12
+ * dead-end at an error page and the whole flow would be unreachable while
13
+ * looking configured.
14
+ *
15
+ * It is absolute and same-origin, resolved SERVER-side from `BETTER_AUTH_URL`
16
+ * and handed down as a prop — never from `window.location`, which is the value
17
+ * an attacker influences and the one the pinned `trustedOrigins` exists to stop
18
+ * trusting.
19
+ *
20
+ * The card swaps to the `sent` intent only on a SUCCESSFUL response, which is
21
+ * what keeps "check your email" honest on a site whose transport is missing:
22
+ * the server refuses, and the visitor sees the error state instead.
23
+ *
24
+ * The success copy is identical for a known and an unknown address — the whole
25
+ * point of the endpoint's constant body — so this component must never branch
26
+ * its rendering on anything the response says about the address.
27
+ */
28
+
29
+ import { AuthCard, type AuthCardLabels } from "@working-theory/ui/blocks";
30
+ import { createAuthClient } from "better-auth/react";
31
+ import type { ReactElement } from "react";
32
+ import { useState } from "react";
33
+
34
+ const authClient = createAuthClient();
35
+
36
+ export interface ForgotPasswordCardProps {
37
+ /** Absolute same-origin URL of `/reset-password`, resolved server-side. */
38
+ resetUrl: string;
39
+ /** Href for the card footer's "Back to sign in". */
40
+ signInHref: string;
41
+ /** Translated AuthCard labels (resolved server-side by the page). */
42
+ labels: AuthCardLabels;
43
+ /** Translated error copy. */
44
+ errorLabels: { requestFailed: string };
45
+ }
46
+
47
+ export function ForgotPasswordCard({
48
+ resetUrl,
49
+ signInHref,
50
+ labels,
51
+ errorLabels,
52
+ }: ForgotPasswordCardProps): ReactElement {
53
+ const [sent, setSent] = useState(false);
54
+ const [sentTo, setSentTo] = useState("");
55
+ const [errors, setErrors] = useState<{ email?: string }>({});
56
+
57
+ const submit = async (email: string): Promise<void> => {
58
+ setErrors({});
59
+ // A network-level failure rejects; a refusal (the method disabled, the
60
+ // rate limit tripped) resolves with `{ error }`. Both land on the same
61
+ // message — neither may hint at whether the address exists.
62
+ const failed = await authClient
63
+ .requestPasswordReset({ email, redirectTo: resetUrl })
64
+ .then(
65
+ (res) => Boolean(res?.error),
66
+ () => true,
67
+ );
68
+
69
+ if (failed) {
70
+ setErrors({ email: errorLabels.requestFailed });
71
+ return;
72
+ }
73
+ setSentTo(email);
74
+ setSent(true);
75
+ };
76
+
77
+ return (
78
+ <div className="flex flex-col gap-4" data-testid="forgot-password-page">
79
+ <AuthCard
80
+ intent={sent ? "sent" : "forgot"}
81
+ signInHref={signInHref}
82
+ labels={labels}
83
+ {...(sent ? { sentTo } : {})}
84
+ onSubmit={(data) => void submit(data.email)}
85
+ errors={errors}
86
+ />
87
+ </div>
88
+ );
89
+ }