create-ailk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -0,0 +1,194 @@
1
+ /**
2
+ * The /api/revalidate receiver — the site half of #5851's publish webhook
3
+ * (#5877, spec docs/specs/5877-revalidate-receiver-spec.md).
4
+ *
5
+ * This is the SECOND api-shaped route under apps/web/app/api/, beside
6
+ * app/api/auth/[...all]/route.ts — whose header calls itself "the only
7
+ * api-shaped code permitted in apps/web". That claim is amended here: a
8
+ * push-revalidation receiver has to live on the site it purges, because
9
+ * next/cache's tag store is per-deployment. It holds no content and no
10
+ * database handle; it purges cache keys for a read path that goes through
11
+ * apps/api (ADR 0002, headless-first).
12
+ *
13
+ * The wire contract is #5851's, consumed verbatim (D1): POST; body the
14
+ * four-key JSON { workspaceId, type, slug, locale }; headers
15
+ * `x-ailk-revalidate-signature: sha256=<lowercase hex>` — HMAC-SHA256 over
16
+ * the exact body bytes — and `x-ailk-revalidate-event: publish | unpublish`.
17
+ *
18
+ * The order is 503 → 401 → 400, and the signature is verified over the RAW
19
+ * body before it is parsed (D2). Re-serialising a parsed object changes the
20
+ * bytes, and answering 400 before 401 would hand an unauthenticated caller a
21
+ * JSON-parser oracle.
22
+ *
23
+ * Both events take one code path (D6): purging the page tag is what makes an
24
+ * unpublished page disappear, and purging the list tag drops it from the
25
+ * index — the same two purges a publish needs. The event header is logged and
26
+ * never branched on.
27
+ *
28
+ * No replay window, by design and on the wire (D7): #5851 signs the body
29
+ * bytes alone, so there is no timestamp to check a window against, and the
30
+ * only action a replay causes — a second revalidateTag on an already-purged
31
+ * tag — is a no-op. #5902 is the open amendment that would give this receiver
32
+ * a timestamp; D7 is reopened with it.
33
+ *
34
+ * PII / secret boundary (FR9): no log line carries the secret, the signature
35
+ * header value, the request body, or page content.
36
+ */
37
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
38
+
39
+ import { contentListTag, contentPageTag } from "@working-theory/page-renderer";
40
+ import { revalidateTag } from "next/cache";
41
+
42
+ const SIGNATURE_HEADER = "x-ailk-revalidate-signature";
43
+ const EVENT_HEADER = "x-ailk-revalidate-event";
44
+
45
+ /**
46
+ * A FORMAT guard, not a secret-dependent one — it compares the header against
47
+ * a constant pattern, never against the digest (D3).
48
+ */
49
+ const SIGNATURE_SHAPE = /^sha256=[0-9a-f]{64}$/;
50
+
51
+ type RevalidateBody = {
52
+ workspaceId: string;
53
+ type: string;
54
+ slug: string;
55
+ locale: string;
56
+ };
57
+
58
+ /**
59
+ * Line-for-line the repo's one shipped verifier
60
+ * (apps/api/src/routes/slack/actions.ts:50-62), with the base string changed
61
+ * from Slack's `v0:<ts>:<body>` to the raw body. The length guard is required,
62
+ * not decorative: timingSafeEqual throws on unequal lengths. The `sha256=`
63
+ * prefix stays inside the compared material so a same-length `sha512=` header
64
+ * is rejected outright rather than compared against a sha256 digest.
65
+ */
66
+ function isValidSignature(
67
+ rawBody: string,
68
+ provided: string,
69
+ secret: string,
70
+ ): boolean {
71
+ const expected = `sha256=${createHmac("sha256", secret).update(rawBody, "utf8").digest("hex")}`;
72
+ const expectedBuf = Buffer.from(expected, "utf8");
73
+ const providedBuf = Buffer.from(provided, "utf8");
74
+ if (expectedBuf.length !== providedBuf.length) return false;
75
+ return timingSafeEqual(expectedBuf, providedBuf);
76
+ }
77
+
78
+ /** The parsed four-key body, or null if the payload is not one (FR5). */
79
+ function parseBody(rawBody: string): RevalidateBody | null {
80
+ let parsed: unknown;
81
+ try {
82
+ parsed = JSON.parse(rawBody);
83
+ } catch {
84
+ return null;
85
+ }
86
+ // The array clause is belt-and-braces: the four string checks below already
87
+ // reject `[]`. It stays because FR5 says "a non-object" and an
88
+ // unauthenticated path is the wrong place to make a reader derive that.
89
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
90
+ return null;
91
+ }
92
+ const { workspaceId, type, slug, locale } = parsed as Record<string, unknown>;
93
+ if (
94
+ typeof workspaceId !== "string" ||
95
+ typeof type !== "string" ||
96
+ typeof slug !== "string" ||
97
+ typeof locale !== "string"
98
+ ) {
99
+ return null;
100
+ }
101
+ return { workspaceId, type, slug, locale };
102
+ }
103
+
104
+ export async function POST(request: Request): Promise<Response> {
105
+ // Honoured if a proxy or a future signer supplies one, else minted so the
106
+ // single line for one delivery is joinable. #5851 ships no correlation
107
+ // header, so roadmap §7.4's end-to-end trace is NOT closed by this route
108
+ // (D8) — it is recorded on #5851, not absorbed here.
109
+ const correlationId = request.headers.get("x-request-id") ?? randomUUID();
110
+
111
+ const rawEvent = request.headers.get(EVENT_HEADER);
112
+ const event =
113
+ rawEvent === "publish" || rawEvent === "unpublish" ? rawEvent : "unknown";
114
+
115
+ /**
116
+ * The one place a line is emitted (FR8), so the `[revalidate]` prefix, the
117
+ * correlation id and the event cannot drift between rungs — and so FR9's
118
+ * "no secret, no signature, no body" boundary has ONE site to audit rather
119
+ * than four. Severity carries the outcome so an operator can alert on
120
+ * `error` alone (D9).
121
+ */
122
+ const log = (
123
+ level: "error" | "warn" | "info",
124
+ fields: Record<string, unknown>,
125
+ ): void => {
126
+ console[level](
127
+ `[revalidate] ${JSON.stringify({ correlationId, event, ...fields })}`,
128
+ );
129
+ };
130
+
131
+ // Read per request, never at module load (D10): a module-scope const latches
132
+ // the value at cold start, so a secret added to a running deployment would
133
+ // keep returning 503 until the lambda recycles.
134
+ const secret = process.env.CONTENT_REVALIDATE_SECRET;
135
+ if (secret === undefined || secret.length === 0) {
136
+ log("error", {
137
+ status: 503,
138
+ reason:
139
+ "CONTENT_REVALIDATE_SECRET unset — the site is not push-revalidated",
140
+ });
141
+ return Response.json({ error: "not_configured" }, { status: 503 });
142
+ }
143
+
144
+ // Read once, as text, and kept as the signed material — never re-serialised
145
+ // (FR3).
146
+ const rawBody = await request.text();
147
+
148
+ const provided = request.headers.get(SIGNATURE_HEADER);
149
+ if (
150
+ provided === null ||
151
+ !SIGNATURE_SHAPE.test(provided) ||
152
+ !isValidSignature(rawBody, provided, secret)
153
+ ) {
154
+ log("warn", { status: 401 });
155
+ return Response.json({ error: "invalid_signature" }, { status: 401 });
156
+ }
157
+
158
+ const body = parseBody(rawBody);
159
+ if (body === null) {
160
+ log("warn", { status: 400 });
161
+ return Response.json({ error: "invalid_body" }, { status: 400 });
162
+ }
163
+
164
+ // The receiver formats no tag (D5). Both strings come from #5875's
165
+ // cache-tags.ts, which the read path calls too — one exported pair, one
166
+ // process, so reader and purger cannot drift. Neither builder takes
167
+ // `workspaceId`: the {workspace} segment is siteWorkspaceSegment(), a
168
+ // property of the deployment that the webhook body cannot influence.
169
+ const pageTag = contentPageTag({
170
+ type: body.type,
171
+ slug: body.slug,
172
+ locale: body.locale,
173
+ });
174
+ const listTag = contentListTag({ type: body.type });
175
+
176
+ // The two-argument form, pinned to next@16.3.3: `profile` is not optional in
177
+ // the declared type, and `{ expire: 0 }` lands on the same
178
+ // immediate-expiration branch as the deprecated one-argument call. Not
179
+ // `"max"` — that carries stale: 300 s, which argues directly against the
180
+ // 60 s budget this route exists to meet (D4).
181
+ revalidateTag(pageTag, { expire: 0 });
182
+ revalidateTag(listTag, { expire: 0 });
183
+
184
+ log("info", {
185
+ status: 200,
186
+ workspaceId: body.workspaceId,
187
+ type: body.type,
188
+ slug: body.slug,
189
+ locale: body.locale,
190
+ pageTag,
191
+ listTag,
192
+ });
193
+ return Response.json({ revalidated: true }, { status: 200 });
194
+ }
@@ -30,7 +30,34 @@ import { betterAuth } from "better-auth";
30
30
  import { prismaAdapter } from "better-auth/adapters/prisma";
31
31
  import { nextCookies } from "better-auth/next-js";
32
32
 
33
- import { getActiveProviderIds } from "./lib/auth-env";
33
+ import {
34
+ authEmailTransport,
35
+ makeSendPasswordChangedNotice,
36
+ makeSendResetPassword,
37
+ makeSendVerificationEmail,
38
+ } from "./lib/auth-email";
39
+ import {
40
+ getActiveProviderIds,
41
+ isSignUpOpen,
42
+ resetSendConfigured,
43
+ } from "./lib/auth-env";
44
+
45
+ /**
46
+ * Reset-token lifetime (#5962 § D11). One hour — the library's own default,
47
+ * set explicitly so the number is reviewable rather than implicit. It is a
48
+ * live credential: the window should be the shortest one a person can
49
+ * realistically act inside.
50
+ */
51
+ const RESET_TOKEN_TTL_SECONDS = 3600;
52
+
53
+ /**
54
+ * Verification-token lifetime (§ D11). Twenty-four hours. Unlike the reset
55
+ * token this is NOT a credential — it is a signed JWT whose blast radius is
56
+ * `emailVerified: true` on one row — so its failure mode is a person who
57
+ * opened the mail next morning having to ask again. The library's 3600 s
58
+ * default is too short for that; a day is not.
59
+ */
60
+ const VERIFY_TOKEN_TTL_SECONDS = 86_400;
34
61
 
35
62
  // Lazy-load prisma-for-auth: import is deferred until createAuth() is called
36
63
  // without an explicit `prisma` dep. This prevents the PrismaClient singleton
@@ -44,6 +71,16 @@ function getDefaultPrisma(): PrismaClient {
44
71
  return prismaForAuth;
45
72
  }
46
73
 
74
+ /**
75
+ * The one delegate method `onPasswordReset` reaches for, named so the call
76
+ * site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
77
+ * uses it: the generated `PrismaClient` satisfies this structurally, and
78
+ * nothing wider than `verification.deleteMany` is reachable through it.
79
+ */
80
+ interface VerificationStore {
81
+ verification: { deleteMany(args: unknown): Promise<unknown> };
82
+ }
83
+
47
84
  type SocialProviders = {
48
85
  google?: { clientId: string; clientSecret: string };
49
86
  github?: { clientId: string; clientSecret: string };
@@ -76,15 +113,88 @@ function buildSocialProviders(): SocialProviders {
76
113
  }
77
114
 
78
115
  /**
79
- * Resolve the secret used to sign session cookies. Better Auth requires a
80
- * secret in production. In dev / test the bare default keeps the app bootable
81
- * without configuration; production deployments MUST set BETTER_AUTH_SECRET.
116
+ * Resolve the secret used to sign session cookies AND — since #5962 § D9 — the
117
+ * email-verification JWTs.
118
+ *
119
+ * THROWS in production when BETTER_AUTH_SECRET is unset (§ D16). Better Auth's
120
+ * own `validateSecret` throws only when the secret equals ITS default; this
121
+ * repo's literal is a different string, so it cleared that check and produced
122
+ * nothing but a low-entropy warning — while sitting in a repository whose
123
+ * `oss`-tier tree ships publicly. Anyone who can read this file could forge
124
+ * both a session cookie and a verification token on any deployment that
125
+ * omitted the variable.
126
+ *
127
+ * This changes boot behaviour: a production deployment with no
128
+ * BETTER_AUTH_SECRET boots today and does not after this. That is the correct
129
+ * failure — a site signing sessions with a published string is not usefully
130
+ * "up" — and `apps/web/.env.example` has always documented the variable as
131
+ * REQUIRED in production. The change makes the file honest rather than adding
132
+ * a requirement.
82
133
  */
83
134
  function resolveSecret(): string {
84
- return (
85
- process.env.BETTER_AUTH_SECRET ??
86
- "ailk-dev-secret-do-not-use-in-production"
87
- );
135
+ const configured = process.env.BETTER_AUTH_SECRET;
136
+ if (configured) return configured;
137
+
138
+ // SERVING, not BUILDING. `next build` collects page data with
139
+ // NODE_ENV=production, and that evaluates `app/api/auth/[...all]/route.ts`,
140
+ // which resolves the lazy `auth` proxy — so an unqualified production throw
141
+ // fails the BUILD of any tree that has not set the variable. That is much
142
+ // broader than the risk: a build produces an artifact, it does not serve a
143
+ // session, and it would break CI, preview builds, and `pnpm build` on a
144
+ // freshly scaffolded site that has not configured auth at all.
145
+ //
146
+ // Next sets NEXT_PHASE to PHASE_PRODUCTION_BUILD ("phase-production-build")
147
+ // for the duration of the build, so excluding that phase keeps the guard
148
+ // exactly where it was aimed: a production process that is about to answer
149
+ // requests. Such a process still throws on the first access to `auth`, which
150
+ // is the intended failure — a site signing sessions with a string published
151
+ // in this repository is not usefully "up".
152
+ const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
153
+ if (process.env.NODE_ENV === "production" && !isBuildPhase) {
154
+ throw new Error(
155
+ "BETTER_AUTH_SECRET is required in production: it signs session cookies and email-verification tokens, and the development fallback is published in this repository.",
156
+ );
157
+ }
158
+
159
+ return "ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
160
+ }
161
+
162
+ /**
163
+ * `advanced.ipAddress.trustedProxies` from AILK_AUTH_TRUSTED_PROXIES (§ D8b).
164
+ *
165
+ * DEPLOYMENT REQUIREMENT when this IS set (security review round 2, finding 2):
166
+ * the origin must not be reachable except through the declared proxies. This
167
+ * guard keys on the VARIABLE being set, not on the IP being resolvable on each
168
+ * request, and `getIPFromHeader` still returns null for a request with no
169
+ * `x-forwarded-for` at all — which would put that request in the shared
170
+ * `no-trusted-ip` bucket WITH the 900 s rules applied. A correctly fronted
171
+ * origin cannot receive such a request (the edge always appends the caller's
172
+ * own peer address), so this is not attacker-reachable there; an origin that
173
+ * is ALSO directly reachable makes it reachable, and the blast radius is
174
+ * site-wide reset denial at five requests per 900 s. Resolving it in code
175
+ * would mean importing `@better-auth/core`'s internal `getIp` — an undeclared
176
+ * transitive dependency — so it is stated as a requirement instead.
177
+ *
178
+ * Better Auth's `getIPFromHeader` returns `null` for any multi-hop
179
+ * `x-forwarded-for` unless this is configured, and a null IP degrades the rate
180
+ * limiter to ONE SHARED BUCKET for the whole deployment — five reset requests
181
+ * from anyone in fifteen minutes would lock out every visitor. The opposite
182
+ * failure is just as real: with a single-hop XFF and no trustedProxies, the
183
+ * client's own header is trusted and an attacker mints a fresh bucket per
184
+ * request.
185
+ *
186
+ * Unset is the honest default: this repo cannot know a downstream site's edge
187
+ * topology, and guessing one is worse than saying so. Both failure modes are
188
+ * documented in `.env.example` beside the variable.
189
+ */
190
+ function resolveTrustedProxies(): string[] | undefined {
191
+ const raw = process.env.AILK_AUTH_TRUSTED_PROXIES;
192
+ if (!raw) return undefined;
193
+ const entries = raw
194
+ .split(",")
195
+ .map((entry) => entry.trim())
196
+ .filter((entry) => entry.length > 0);
197
+ return entries.length > 0 ? entries : undefined;
88
198
  }
89
199
 
90
200
  export interface CreateAuthDeps {
@@ -106,6 +216,111 @@ export interface CreateAuthDeps {
106
216
  */
107
217
  export function createAuth(deps: CreateAuthDeps = {}) {
108
218
  const prisma = deps.prisma ?? getDefaultPrisma();
219
+
220
+ /**
221
+ * ONE predicate, read in ONE place (#5962 § D2).
222
+ *
223
+ * AC-2 requires that with the password method disabled the "Forgot
224
+ * password?" link is not rendered. That link lives inside `AuthCard`'s
225
+ * password body, which renders iff the card's `method === "password"`,
226
+ * which `sign-in/page.tsx` derives from `getActiveProviderIds()` and
227
+ * nothing else. Keying `emailAndPassword.enabled` on any OTHER predicate —
228
+ * a new AILK_AUTH_PASSWORD flag, say — would let the two disagree, and
229
+ * AC-2 would be false in the disagreeing configuration.
230
+ */
231
+ const passwordMethodActive = getActiveProviderIds().includes("email");
232
+
233
+ /**
234
+ * Whether the reset CALLBACKS are wired at all (§ D2, § D10).
235
+ *
236
+ * Load-bearing: Better Auth's `/request-password-reset` refuses only when
237
+ * `sendResetPassword` is ABSENT. So a defined callback on a site whose page
238
+ * renders the not-available state would be a live anonymous mail-sending
239
+ * surface behind a UI that denies it exists. One predicate decides both.
240
+ */
241
+ const resetConfigured = resetSendConfigured();
242
+
243
+ const trustedProxies = resolveTrustedProxies();
244
+
245
+ /** Plain assignment, no cast — see `VerificationStore`. */
246
+ const verificationStore: VerificationStore = prisma;
247
+
248
+ // No cast: `AuthEmailPrisma` is declared with method shorthand, which the
249
+ // generated `PrismaClient` satisfies structurally (see its docblock).
250
+ const sendResetPassword = makeSendResetPassword({
251
+ prisma,
252
+ send: authEmailTransport,
253
+ });
254
+ const sendVerificationEmail = makeSendVerificationEmail({
255
+ send: authEmailTransport,
256
+ });
257
+ const sendPasswordChangedNotice = makeSendPasswordChangedNotice({
258
+ send: authEmailTransport,
259
+ });
260
+
261
+ /**
262
+ * Better Auth consumes only the token that was presented
263
+ * (`password.mjs`'s `consumeVerificationValue` deletes ONE row). Every OTHER
264
+ * live reset token for this user is still a working credential, so AC-1's
265
+ * "invalidates … every other outstanding token for that user" has to happen
266
+ * here — the library does not do it at any version in this line.
267
+ *
268
+ * `Verification.value` holds the user id and `identifier` is
269
+ * `reset-password:<token>`. There is no index on `value`, so this is a
270
+ * sequential scan; accepted rather than overlooked, because it runs once per
271
+ * COMPLETED reset — the rarest write in this flow — against a table whose
272
+ * rows are short-lived. An index bought for that is a schema change bought
273
+ * with nothing.
274
+ *
275
+ * Both halves are guarded: the reset itself has ALREADY SUCCEEDED by the
276
+ * time this runs, so neither a failed sibling delete nor a failed
277
+ * notification may turn a completed password change into a 500.
278
+ */
279
+ const onPasswordReset = async ({
280
+ user,
281
+ }: {
282
+ user: { id: string; email: string };
283
+ }): Promise<void> => {
284
+ try {
285
+ await verificationStore.verification.deleteMany({
286
+ where: {
287
+ value: user.id,
288
+ identifier: { startsWith: "reset-password:" },
289
+ },
290
+ });
291
+ } catch {
292
+ console.warn(
293
+ `[auth-email] ${JSON.stringify({
294
+ userId: user.id,
295
+ kind: "password-reset",
296
+ reason: "sibling_invalidation_failed",
297
+ })}`,
298
+ );
299
+ }
300
+ /**
301
+ * Guarded like its sibling above, though `lib/auth-email.ts` now also
302
+ * guards its own signing step (#6068). BOTH, deliberately: this callback
303
+ * runs AFTER the password has changed and the presented token has been
304
+ * consumed, so a rejection here is not a failed reset — it is a 500 shown
305
+ * to a visitor whose password DID change, over a token they cannot retry
306
+ * with. That outcome must not depend on a promise made in another file
307
+ * staying true; the notice is best-effort by design, and the completed
308
+ * reset outranks it.
309
+ */
310
+ try {
311
+ await sendPasswordChangedNotice(user);
312
+ } catch {
313
+ console.warn(
314
+ `[auth-email] ${JSON.stringify({
315
+ userId: user.id,
316
+ kind: "password-changed",
317
+ reason: "send_failed",
318
+ errorKind: "notice_threw",
319
+ })}`,
320
+ );
321
+ }
322
+ };
323
+
109
324
  return betterAuth({
110
325
  database: prismaAdapter(prisma, {
111
326
  provider: "postgresql",
@@ -113,6 +328,139 @@ export function createAuth(deps: CreateAuthDeps = {}) {
113
328
  secret: resolveSecret(),
114
329
  baseURL: process.env.BETTER_AUTH_URL,
115
330
  socialProviders: buildSocialProviders(),
331
+ /**
332
+ * The emitted reset link's host and the `redirectTo` allow-list are
333
+ * PINNED, never derived from the request (§ D13).
334
+ *
335
+ * With `baseURL` falsy — every deployment that has not set the variable —
336
+ * Better Auth recomputes both PER REQUEST from the request itself. Before
337
+ * this issue that was a latent host-header bug with no credential behind
338
+ * it. The moment the password method is on it becomes an account-takeover
339
+ * chain: `Host: evil.tld` with `redirectTo: https://evil.tld/x` passes an
340
+ * attacker-derived origin check, the emailed link is built on the
341
+ * attacker's host, and `/reset-password/:token` then redirects the live
342
+ * token to the attacker's origin.
343
+ *
344
+ * `BETTER_AUTH_URL` is also a conjunct of `resetSendConfigured()`, so a
345
+ * deployment that has not pinned its origin never offers reset at all.
346
+ */
347
+ ...(process.env.BETTER_AUTH_URL
348
+ ? { trustedOrigins: [process.env.BETTER_AUTH_URL] }
349
+ : {}),
350
+ emailAndPassword: {
351
+ enabled: passwordMethodActive,
352
+ // Turning the password method on also opens POST /sign-up/email —
353
+ // anonymous account creation — and that endpoint answers a taken
354
+ // address with a distinguishable USER_ALREADY_EXISTS, a direct existence
355
+ // oracle no care on /request-password-reset offsets. Default closed.
356
+ disableSignUp: !isSignUpOpen(),
357
+ // Departs from apps-paid/dashboard-web's `true` deliberately:
358
+ // auto-signing-in on account creation means an unverified address holds
359
+ // a session.
360
+ autoSignIn: false,
361
+ // Keeps the library default. Flipping it would change sign-IN behaviour
362
+ // for every existing account on every upgrading site — beyond this issue.
363
+ requireEmailVerification: false,
364
+ // So an attacker holding a stolen session loses it when the real owner
365
+ // resets. Opt-in in the library; not on today.
366
+ revokeSessionsOnPasswordReset: true,
367
+ // Overrides the library's default of 8 — the ONLY check
368
+ // `/reset-password` applies to the new password, and the reset endpoint
369
+ // is precisely where a weak one gets set. No composition rules: they
370
+ // push people toward predictable substitutions and away from length.
371
+ minPasswordLength: 12,
372
+ resetPasswordTokenExpiresIn: RESET_TOKEN_TTL_SECONDS,
373
+ ...(resetConfigured ? { sendResetPassword } : {}),
374
+ /**
375
+ * UNCONDITIONAL, unlike `sendResetPassword` (code review, AC-2).
376
+ *
377
+ * Better Auth registers its `/reset-password` POST on
378
+ * `emailAndPassword.enabled` — `passwordMethodActive` — not on
379
+ * `resetConfigured`. So for the token's whole hour after an operator
380
+ * removes any send conjunct, an already-emitted link is still
381
+ * consumable. Gating this callback too would make exactly those resets
382
+ * the ones that skip sibling invalidation and the § D17 notice — the
383
+ * degraded configuration getting the weaker guarantee.
384
+ *
385
+ * It is safe unconditionally because it cannot run without a valid
386
+ * token, which cannot exist without a mail this deployment once sent;
387
+ * and `sendPasswordChangedNotice` logs `not_configured` and returns
388
+ * rather than throwing when the transport is gone (lib/auth-email.ts).
389
+ */
390
+ onPasswordReset,
391
+ },
392
+ emailVerification: {
393
+ sendOnSignUp: true,
394
+ autoSignInAfterVerification: false,
395
+ expiresIn: VERIFY_TOKEN_TTL_SECONDS,
396
+ ...(resetConfigured ? { sendVerificationEmail } : {}),
397
+ },
398
+ /**
399
+ * Rate limiting (§ D8a). `enabled` is set explicitly rather than left to
400
+ * the library's `?? isProduction`: a preview deployment is as enumerable
401
+ * as production.
402
+ *
403
+ * THE WIDENED WINDOWS ARE CONDITIONAL ON A RESOLVABLE CLIENT IP, and that
404
+ * condition is the whole of this block's safety.
405
+ *
406
+ * Better Auth keys the limiter on `getIp()`. With a multi-hop
407
+ * `x-forwarded-for` and no `trustedProxies`, `getIPFromHeader` returns
408
+ * `null` (`forwardedIps.length !== 1`), and the limiter then keys on the
409
+ * literal constant `"no-trusted-ip"` — ONE BUCKET PER PATH FOR THE WHOLE
410
+ * DEPLOYMENT, shared by every visitor. A multi-hop XFF is what a typical
411
+ * platform edge presents, and `AILK_AUTH_TRUSTED_PROXIES` is unset by
412
+ * default because this repo cannot know a downstream site's topology.
413
+ *
414
+ * In that state a 900 s rule is not a rate limit, it is a lockout
415
+ * primitive: five anonymous POSTs to `/request-password-reset` deny
416
+ * password reset to EVERY user of the site, and since `decideConsume`
417
+ * does not refresh `lastRequest` on a denied request, ~5 requests per
418
+ * 900 s holds it there indefinitely. The library's own default for these
419
+ * paths is 60 s / 3 — the same shared bucket, but an outage that recovers
420
+ * on its own in a minute. Setting the wider rules unconditionally would
421
+ * therefore make the default deployment 15× WORSE than configuring
422
+ * nothing at all.
423
+ *
424
+ * So: when the IP is resolvable the § D8a values apply as specified; when
425
+ * it is not, we fall through to the library's shorter-recovery defaults.
426
+ * This is the same reasoning `apps/api/src/lib/auth-email-budget.ts`
427
+ * applies to the site-wide send cap it declines to implement — a
428
+ * site-wide cap an attacker can hold open is a denial of the control it
429
+ * implements — and the two now agree instead of contradicting each other.
430
+ *
431
+ * STORAGE CAVEAT, load-bearing and NOT solved here: `"memory"` is a
432
+ * process-local Map, so on a multi-instance deployment the effective
433
+ * ceiling is 5 × N per 900 s — the identical caveat
434
+ * `apps/api/src/middleware/rate-limit.ts` documents for itself. The
435
+ * durable alternative needs a rate-limit table the schema does not have,
436
+ * and this issue adds no migration. What does bound the consequence is
437
+ * the per-recipient send budget at the apps/api relay.
438
+ */
439
+ rateLimit: {
440
+ enabled: true,
441
+ storage: "memory",
442
+ // The GLOBAL window/max are inside the guard too, not just customRules
443
+ // (security review round 2, finding 1). They apply to every auth path
444
+ // the library's special rules do NOT cover — `/verify-email`,
445
+ // `/reset-password`, the OAuth callbacks, `/get-session`. Under the
446
+ // shared `no-trusted-ip` bucket a global 60/60 s is TIGHTER than the
447
+ // library's own 10 s/100 default, so setting it unconditionally made
448
+ // those paths ~10x cheaper to exhaust and ~6x slower to recover than
449
+ // `main` — one caller at ~1 rps could deny email verification, reset
450
+ // completion and OAuth sign-in to the whole site, and ordinary traffic
451
+ // on `/get-session` would trip it without an attacker at all.
452
+ ...(trustedProxies
453
+ ? {
454
+ window: 60,
455
+ max: 60,
456
+ customRules: {
457
+ "/request-password-reset": { window: 900, max: 5 },
458
+ "/reset-password": { window: 900, max: 10 },
459
+ "/send-verification-email": { window: 900, max: 5 },
460
+ },
461
+ }
462
+ : {}),
463
+ },
116
464
  // Ids are minted by the DATABASE (ADR-0031): generateId: false makes the
117
465
  // Prisma adapter omit `id` on insert so the dbgenerated prefixed-UUIDv7
118
466
  // default fires (user_/sess_/acct_/verify_). Do not re-enable.
@@ -120,6 +468,57 @@ export function createAuth(deps: CreateAuthDeps = {}) {
120
468
  database: {
121
469
  generateId: false,
122
470
  },
471
+ ...(trustedProxies ? { ipAddress: { trustedProxies } } : {}),
472
+ /**
473
+ * The timing half of AC-1 (§ D6).
474
+ *
475
+ * `runInBackgroundOrAwait` AWAITS `sendResetPassword` unless this
476
+ * handler is set. A Resend round-trip is tens-to-hundreds of
477
+ * milliseconds; the unknown-address branch's dummy lookup is one indexed
478
+ * read. Identical bodies, non-identical latencies — the exact oracle
479
+ * AC-1's "timing-safe behaviour" names.
480
+ *
481
+ * Next's `after()` runs the callback AFTER the response is flushed,
482
+ * inside the same request's lifetime, so a serverless function is not
483
+ * torn down mid-send. It THROWS outside a Next request scope (a direct
484
+ * `auth.api.*` call from a script), and the library's own try/catch
485
+ * would swallow that into one logger.error — dropping the mail after the
486
+ * endpoint already returned 200. The fallback keeps the promise alive.
487
+ *
488
+ * THIS HANDLER IS GLOBAL, not scoped to reset: `runInBackgroundOrAwait`
489
+ * reads one handler for every call site, and ten others exist in the
490
+ * installed tree (change-email, delete-account, sign-up). None is
491
+ * enabled on this build today, and any future feature that enables one
492
+ * inherits fire-and-forget delivery. Stated here so the next author
493
+ * finds it rather than discovers it.
494
+ *
495
+ * The cost: a backgrounded send returns 200 before the send is known to
496
+ * have succeeded, so a failed send is invisible to the visitor. That is
497
+ * already the correct visitor-facing behaviour — telling them the send
498
+ * failed for THIS address is itself an oracle — and the operator's
499
+ * signal is the log line in lib/auth-email.ts.
500
+ */
501
+ backgroundTasks: {
502
+ handler: (promise: Promise<unknown>): void => {
503
+ try {
504
+ // Required lazily, not imported at module scope: `next/server`
505
+ // pulls in the Edge runtime's `Request` global, which this
506
+ // module's own test harness (and any plain-Node importer of
507
+ // `createAuth`) does not provide. The deferral keeps `auth.ts`
508
+ // importable outside a Next request.
509
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
510
+ const { after } = require("next/server") as {
511
+ after: (task: () => unknown) => void;
512
+ };
513
+ after(() => promise);
514
+ } catch {
515
+ // `after()` throws outside a Next request scope (a direct
516
+ // auth.api.* call from a script). Keep the promise alive rather
517
+ // than letting the library's try/catch swallow the send.
518
+ void promise.catch(() => {});
519
+ }
520
+ },
521
+ },
123
522
  },
124
523
  // Persist sessions in the database so admin revocation by row delete remains
125
524
  // the model the dual-mode preHandler relies on.
@@ -84,11 +84,15 @@ module.exports = {
84
84
  "<rootDir>/node_modules/@working-theory/site-config/dist/schema/index.js",
85
85
  "^@working-theory/validation$": "<rootDir>/node_modules/@working-theory/validation/dist/index.js",
86
86
  "^@working-theory/web-i18n$": "<rootDir>/node_modules/@working-theory/web-i18n/dist/index.js",
87
- // The dependency-free tenancy-constants subpath (database/tenancy.ts —
88
- // it imports only a type from @working-theory/ids). Mapped to source like
89
- // every sibling above; `database/dist` is not built in this workspace
90
- // pre-`pnpm build`.
87
+ // The dependency-free tenancy-constants subpath (database/tenancy.ts).
88
+ // Mapped to source like every sibling above; `database/dist` is not
89
+ // built in this workspace pre-`pnpm build`.
91
90
  "^@working-theory/database/tenancy$": "<rootDir>/node_modules/@working-theory/database/dist/tenancy.js",
91
+ // #5876: database/tenancy.ts imports isSentinel as a VALUE (not just the
92
+ // Id type) from @working-theory/ids, so ts-jest needs it resolvable at
93
+ // runtime here too — mirrors the same mapping added to
94
+ // database/jest.config.cjs and apps/mcp/jest.config.cjs.
95
+ "^@working-theory/ids$": "<rootDir>/node_modules/@working-theory/ids/dist/index.js",
92
96
  // #5025: pnpm keys next-intl's virtual-store path on its resolved peers,
93
97
  // so apps/web and packages/page-renderer can link two PHYSICALLY DISTINCT
94
98
  // copies of the same version — and a `jest.mock("next-intl/server")` in