create-ailk 0.1.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 (533) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +39 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/copy.d.ts +8 -0
  6. package/dist/copy.js +109 -0
  7. package/dist/index.d.ts +10 -0
  8. package/dist/index.js +10 -0
  9. package/dist/lib/pro-callout.d.ts +12 -0
  10. package/dist/lib/pro-callout.js +20 -0
  11. package/dist/parse-args.d.ts +13 -0
  12. package/dist/parse-args.js +81 -0
  13. package/dist/programmatic.d.ts +49 -0
  14. package/dist/programmatic.js +89 -0
  15. package/dist/substitute.d.ts +31 -0
  16. package/dist/substitute.js +45 -0
  17. package/dist/validate.d.ts +8 -0
  18. package/dist/validate.js +70 -0
  19. package/package.json +47 -0
  20. package/templates/.claude/.gitkeep +0 -0
  21. package/templates/.claude/agents/web.md +80 -0
  22. package/templates/.claude/rules/architecture.md +62 -0
  23. package/templates/.claude/rules/preserve-existing-style.md +51 -0
  24. package/templates/.claude/skills/README.md +43 -0
  25. package/templates/.claude/skills/add-schema/SKILL.md +270 -0
  26. package/templates/.claude/skills/add-schema/references/conflict-handling.md +200 -0
  27. package/templates/.claude/skills/add-schema/references/file-finding.md +123 -0
  28. package/templates/.claude/skills/add-schema/references/schema-types.md +131 -0
  29. package/templates/.claude/skills/audit-site/SKILL.md +213 -0
  30. package/templates/.claude/skills/audit-site/references/common-errors.md +113 -0
  31. package/templates/.claude/skills/audit-site/references/findings-glossary.md +138 -0
  32. package/templates/.claude/skills/audit-site/references/severity-levels.md +114 -0
  33. package/templates/.claude/skills/provision-config/SKILL.md +243 -0
  34. package/templates/.claude/skills/provision-config/references/env-vars-template.md +184 -0
  35. package/templates/.claude/skills/provision-config/references/runbook-template.md +248 -0
  36. package/templates/.claude/skills/provision-config/references/vercel-json-template.md +98 -0
  37. package/templates/.claude/skills/scaffold-auth/SKILL.md +261 -0
  38. package/templates/.claude/skills/scaffold-auth/references/env-vars.md +91 -0
  39. package/templates/.claude/skills/scaffold-auth/references/oauth-app-setup.md +171 -0
  40. package/templates/.claude/skills/scaffold-commerce/SKILL.md +310 -0
  41. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +71 -0
  42. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +83 -0
  43. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +72 -0
  44. package/templates/.claude/skills/scaffold-page/SKILL.md +243 -0
  45. package/templates/.claude/skills/scaffold-page/references/page-types.md +169 -0
  46. package/templates/.claude/skills/scaffold-page/templates/content.mdx.tmpl +28 -0
  47. package/templates/.claude/skills/scaffold-page/templates/route.tsx.tmpl +28 -0
  48. package/templates/.claude/skills/suggest-site-pages/SKILL.md +242 -0
  49. package/templates/.claude/skills/suggest-site-pages/references/categories.md +287 -0
  50. package/templates/.env.example +205 -0
  51. package/templates/.gitkeep +2 -0
  52. package/templates/CLAUDE.md +142 -0
  53. package/templates/CONVENTIONS.md +222 -0
  54. package/templates/README.md +110 -0
  55. package/templates/apps/api/.env.example +40 -0
  56. package/templates/apps/api/CLAUDE.md +63 -0
  57. package/templates/apps/api/api/index.ts +144 -0
  58. package/templates/apps/api/jest.config.cjs +74 -0
  59. package/templates/apps/api/package.json +54 -0
  60. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +138 -0
  61. package/templates/apps/api/src/__tests__/factories/index.ts +70 -0
  62. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +82 -0
  63. package/templates/apps/api/src/__tests__/index.test.ts +118 -0
  64. package/templates/apps/api/src/__tests__/server.test.ts +207 -0
  65. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +126 -0
  66. package/templates/apps/api/src/__tests__/setup.ts +24 -0
  67. package/templates/apps/api/src/cli.ts +30 -0
  68. package/templates/apps/api/src/config/__tests__/index.test.ts +97 -0
  69. package/templates/apps/api/src/config/index.ts +56 -0
  70. package/templates/apps/api/src/index.ts +32 -0
  71. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +15 -0
  72. package/templates/apps/api/src/lib/__mocks__/prisma.ts +45 -0
  73. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +178 -0
  74. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +100 -0
  75. package/templates/apps/api/src/lib/__tests__/errors.test.ts +94 -0
  76. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +144 -0
  77. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +8 -0
  78. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +227 -0
  79. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +165 -0
  81. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +129 -0
  82. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +74 -0
  83. package/templates/apps/api/src/lib/content-adapter.ts +86 -0
  84. package/templates/apps/api/src/lib/error-serializer.ts +77 -0
  85. package/templates/apps/api/src/lib/errors.ts +95 -0
  86. package/templates/apps/api/src/lib/file-fallback.ts +87 -0
  87. package/templates/apps/api/src/lib/lead-capture-store.ts +110 -0
  88. package/templates/apps/api/src/lib/locales.ts +201 -0
  89. package/templates/apps/api/src/lib/outbound-http.ts +122 -0
  90. package/templates/apps/api/src/lib/prisma.ts +21 -0
  91. package/templates/apps/api/src/lib/require-paid-tier.ts +53 -0
  92. package/templates/apps/api/src/lib/stripe.ts +30 -0
  93. package/templates/apps/api/src/lib/webhook-idempotency.ts +92 -0
  94. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +111 -0
  95. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +172 -0
  96. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +119 -0
  97. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +138 -0
  98. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +149 -0
  99. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +139 -0
  100. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +175 -0
  101. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +76 -0
  102. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +153 -0
  103. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +72 -0
  104. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +292 -0
  105. package/templates/apps/api/src/middleware/auth.ts +246 -0
  106. package/templates/apps/api/src/middleware/rate-limit.ts +132 -0
  107. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +129 -0
  108. package/templates/apps/api/src/openapi/index.ts +12 -0
  109. package/templates/apps/api/src/openapi/spec.ts +408 -0
  110. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +193 -0
  111. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +201 -0
  112. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +294 -0
  113. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +486 -0
  114. package/templates/apps/api/src/routes/auth/tokens.ts +199 -0
  115. package/templates/apps/api/src/routes/auth/user.ts +244 -0
  116. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +588 -0
  117. package/templates/apps/api/src/routes/checkout/index.ts +13 -0
  118. package/templates/apps/api/src/routes/checkout/sessions.ts +185 -0
  119. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +239 -0
  120. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +201 -0
  121. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +141 -0
  122. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +122 -0
  123. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +44 -0
  124. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +118 -0
  125. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +51 -0
  126. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +252 -0
  127. package/templates/apps/api/src/routes/content/create.ts +114 -0
  128. package/templates/apps/api/src/routes/content/delete.ts +81 -0
  129. package/templates/apps/api/src/routes/content/get-page.ts +98 -0
  130. package/templates/apps/api/src/routes/content/index.ts +32 -0
  131. package/templates/apps/api/src/routes/content/list-pages.ts +83 -0
  132. package/templates/apps/api/src/routes/content/list-types.ts +19 -0
  133. package/templates/apps/api/src/routes/content/rate-limit.ts +13 -0
  134. package/templates/apps/api/src/routes/content/slug.ts +9 -0
  135. package/templates/apps/api/src/routes/content/update.ts +111 -0
  136. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +415 -0
  137. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +165 -0
  138. package/templates/apps/api/src/routes/leads/index.ts +130 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +296 -0
  140. package/templates/apps/api/src/routes/test/auth/session.ts +141 -0
  141. package/templates/apps/api/src/routes/webhooks/README.md +57 -0
  142. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +247 -0
  143. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +198 -0
  144. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +633 -0
  145. package/templates/apps/api/src/routes/webhooks/index.ts +41 -0
  146. package/templates/apps/api/src/routes/webhooks/stripe.ts +253 -0
  147. package/templates/apps/api/src/server.ts +157 -0
  148. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +395 -0
  149. package/templates/apps/api/src/services/enrichment-shim.ts +53 -0
  150. package/templates/apps/api/src/services/lead-routing.ts +338 -0
  151. package/templates/apps/api/tsconfig.json +34 -0
  152. package/templates/apps/api/vercel.json +17 -0
  153. package/templates/apps/mcp/.env.example +52 -0
  154. package/templates/apps/mcp/CLAUDE.md +84 -0
  155. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +52 -0
  156. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +46 -0
  157. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +36 -0
  158. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +54 -0
  159. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +110 -0
  160. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +213 -0
  161. package/templates/apps/mcp/__tests__/audit.test.ts +217 -0
  162. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +52 -0
  163. package/templates/apps/mcp/__tests__/create_page.test.ts +201 -0
  164. package/templates/apps/mcp/__tests__/delete_page.test.ts +176 -0
  165. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +82 -0
  166. package/templates/apps/mcp/__tests__/get_faq.test.ts +80 -0
  167. package/templates/apps/mcp/__tests__/get_locations.test.ts +65 -0
  168. package/templates/apps/mcp/__tests__/get_offerings.test.ts +90 -0
  169. package/templates/apps/mcp/__tests__/get_pricing.test.ts +80 -0
  170. package/templates/apps/mcp/__tests__/get_site_info.test.ts +81 -0
  171. package/templates/apps/mcp/__tests__/http-transport.test.ts +323 -0
  172. package/templates/apps/mcp/__tests__/lib/errors.test.ts +75 -0
  173. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +75 -0
  174. package/templates/apps/mcp/__tests__/list_pages.test.ts +112 -0
  175. package/templates/apps/mcp/__tests__/locale.test.ts +135 -0
  176. package/templates/apps/mcp/__tests__/parity.test.ts +205 -0
  177. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +34 -0
  178. package/templates/apps/mcp/__tests__/read_content.test.ts +153 -0
  179. package/templates/apps/mcp/__tests__/request_callback.test.ts +163 -0
  180. package/templates/apps/mcp/__tests__/search_content.test.ts +153 -0
  181. package/templates/apps/mcp/__tests__/server.test.ts +115 -0
  182. package/templates/apps/mcp/__tests__/submit_lead.test.ts +165 -0
  183. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +161 -0
  184. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +227 -0
  185. package/templates/apps/mcp/__tests__/tools/scaffold.test.ts +237 -0
  186. package/templates/apps/mcp/__tests__/update_page.test.ts +195 -0
  187. package/templates/apps/mcp/jest.config.cjs +82 -0
  188. package/templates/apps/mcp/package.json +37 -0
  189. package/templates/apps/mcp/src/abuse/daily-cap.ts +47 -0
  190. package/templates/apps/mcp/src/abuse/email-validator.ts +25 -0
  191. package/templates/apps/mcp/src/abuse/honeypot.ts +21 -0
  192. package/templates/apps/mcp/src/abuse/index.ts +111 -0
  193. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +58 -0
  194. package/templates/apps/mcp/src/http-transport.ts +303 -0
  195. package/templates/apps/mcp/src/index.ts +67 -0
  196. package/templates/apps/mcp/src/lib/audit-single-page.ts +194 -0
  197. package/templates/apps/mcp/src/lib/errors.ts +61 -0
  198. package/templates/apps/mcp/src/lib/locales.ts +171 -0
  199. package/templates/apps/mcp/src/lib/require-paid-tier.ts +44 -0
  200. package/templates/apps/mcp/src/lib/resolve-locale.ts +30 -0
  201. package/templates/apps/mcp/src/server.ts +139 -0
  202. package/templates/apps/mcp/src/tools/audit.ts +103 -0
  203. package/templates/apps/mcp/src/tools/capture_lead.ts +52 -0
  204. package/templates/apps/mcp/src/tools/create_page.ts +95 -0
  205. package/templates/apps/mcp/src/tools/delete_page.ts +73 -0
  206. package/templates/apps/mcp/src/tools/get_business_hours.ts +66 -0
  207. package/templates/apps/mcp/src/tools/get_faq.ts +69 -0
  208. package/templates/apps/mcp/src/tools/get_locations.ts +85 -0
  209. package/templates/apps/mcp/src/tools/get_offerings.ts +66 -0
  210. package/templates/apps/mcp/src/tools/get_pricing.ts +74 -0
  211. package/templates/apps/mcp/src/tools/get_site_info.ts +84 -0
  212. package/templates/apps/mcp/src/tools/index.ts +125 -0
  213. package/templates/apps/mcp/src/tools/list_pages.ts +72 -0
  214. package/templates/apps/mcp/src/tools/purchase.ts +89 -0
  215. package/templates/apps/mcp/src/tools/read_content.ts +61 -0
  216. package/templates/apps/mcp/src/tools/request_callback.ts +51 -0
  217. package/templates/apps/mcp/src/tools/scaffold.ts +119 -0
  218. package/templates/apps/mcp/src/tools/search_content.ts +59 -0
  219. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +47 -0
  220. package/templates/apps/mcp/src/tools/update_page.ts +91 -0
  221. package/templates/apps/mcp/tsconfig.json +19 -0
  222. package/templates/apps/web/.env.example +47 -0
  223. package/templates/apps/web/CLAUDE.md +81 -0
  224. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +94 -0
  225. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +75 -0
  226. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +82 -0
  227. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +168 -0
  228. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +208 -0
  229. package/templates/apps/web/__tests__/better-stack.test.ts +66 -0
  230. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +87 -0
  231. package/templates/apps/web/__tests__/blog-source-populated.test.ts +130 -0
  232. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +60 -0
  233. package/templates/apps/web/__tests__/google-analytics.test.tsx +104 -0
  234. package/templates/apps/web/__tests__/i18n/middleware.test.ts +119 -0
  235. package/templates/apps/web/__tests__/i18n/routing.test.ts +114 -0
  236. package/templates/apps/web/__tests__/i18n/translations.test.ts +191 -0
  237. package/templates/apps/web/__tests__/json-ld.test.ts +98 -0
  238. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +580 -0
  239. package/templates/apps/web/__tests__/layout-stealth.test.ts +39 -0
  240. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +103 -0
  241. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +114 -0
  242. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +94 -0
  243. package/templates/apps/web/__tests__/page.test.tsx +230 -0
  244. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +321 -0
  245. package/templates/apps/web/__tests__/projects-routes.test.tsx +392 -0
  246. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +475 -0
  247. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +53 -0
  248. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +191 -0
  249. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +123 -0
  250. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +52 -0
  251. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +78 -0
  252. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +203 -0
  253. package/templates/apps/web/__tests__/section-routes.test.tsx +693 -0
  254. package/templates/apps/web/__tests__/security-headers.test.ts +201 -0
  255. package/templates/apps/web/__tests__/sentry-config.test.ts +116 -0
  256. package/templates/apps/web/__tests__/setup.ts +82 -0
  257. package/templates/apps/web/__tests__/sitemap-locales.test.ts +415 -0
  258. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +844 -0
  259. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +81 -0
  260. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +110 -0
  261. package/templates/apps/web/app/.well-known/mcp.json/route.ts +58 -0
  262. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +120 -0
  263. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +202 -0
  264. package/templates/apps/web/app/[locale]/about/page.tsx +31 -0
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +173 -0
  266. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +45 -0
  267. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +117 -0
  268. package/templates/apps/web/app/[locale]/careers/page.tsx +25 -0
  269. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +44 -0
  270. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +113 -0
  271. package/templates/apps/web/app/[locale]/case-studies/page.tsx +25 -0
  272. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +63 -0
  273. package/templates/apps/web/app/[locale]/clients/page.tsx +27 -0
  274. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +130 -0
  275. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +152 -0
  276. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +92 -0
  277. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +79 -0
  278. package/templates/apps/web/app/[locale]/contact/error.tsx +24 -0
  279. package/templates/apps/web/app/[locale]/contact/page.tsx +38 -0
  280. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +34 -0
  281. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +119 -0
  282. package/templates/apps/web/app/[locale]/courses/page.tsx +25 -0
  283. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +160 -0
  284. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +25 -0
  285. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +117 -0
  286. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +58 -0
  287. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +118 -0
  288. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +72 -0
  289. package/templates/apps/web/app/[locale]/downloads/page.tsx +25 -0
  290. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +47 -0
  291. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +130 -0
  292. package/templates/apps/web/app/[locale]/events/page.tsx +25 -0
  293. package/templates/apps/web/app/[locale]/faq/page.tsx +36 -0
  294. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +34 -0
  295. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +78 -0
  296. package/templates/apps/web/app/[locale]/how-to/page.tsx +40 -0
  297. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +43 -0
  298. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +129 -0
  299. package/templates/apps/web/app/[locale]/integrations/page.tsx +25 -0
  300. package/templates/apps/web/app/[locale]/launch-service/page.tsx +31 -0
  301. package/templates/apps/web/app/[locale]/layout.tsx +241 -0
  302. package/templates/apps/web/app/[locale]/page.tsx +41 -0
  303. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +44 -0
  304. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +113 -0
  305. package/templates/apps/web/app/[locale]/partners/page.tsx +25 -0
  306. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +43 -0
  307. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +129 -0
  308. package/templates/apps/web/app/[locale]/products/page.tsx +25 -0
  309. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +39 -0
  310. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +138 -0
  311. package/templates/apps/web/app/[locale]/projects/page.tsx +30 -0
  312. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +51 -0
  313. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +117 -0
  314. package/templates/apps/web/app/[locale]/resources/page.tsx +27 -0
  315. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +43 -0
  316. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +129 -0
  317. package/templates/apps/web/app/[locale]/services/page.tsx +25 -0
  318. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +34 -0
  319. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +81 -0
  320. package/templates/apps/web/app/[locale]/software/page.tsx +39 -0
  321. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +34 -0
  322. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +82 -0
  323. package/templates/apps/web/app/[locale]/tech-articles/page.tsx +39 -0
  324. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +63 -0
  325. package/templates/apps/web/app/[locale]/testimonials/page.tsx +27 -0
  326. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +63 -0
  327. package/templates/apps/web/app/[locale]/use-cases/page.tsx +27 -0
  328. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +34 -0
  329. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +82 -0
  330. package/templates/apps/web/app/[locale]/whitepapers/page.tsx +39 -0
  331. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +152 -0
  332. package/templates/apps/web/app/api/auth/[...all]/route.ts +16 -0
  333. package/templates/apps/web/app/error-fallback.tsx +75 -0
  334. package/templates/apps/web/app/error.tsx +23 -0
  335. package/templates/apps/web/app/layout.tsx +18 -0
  336. package/templates/apps/web/app/llms.txt/route.ts +101 -0
  337. package/templates/apps/web/app/not-found.tsx +48 -0
  338. package/templates/apps/web/app/og/__tests__/route.test.ts +164 -0
  339. package/templates/apps/web/app/og/route.tsx +135 -0
  340. package/templates/apps/web/app/robots.txt/route.ts +36 -0
  341. package/templates/apps/web/app/sitemap.xml/route.ts +146 -0
  342. package/templates/apps/web/auth.ts +141 -0
  343. package/templates/apps/web/components/ThemeToggle.tsx +60 -0
  344. package/templates/apps/web/components/google-analytics.tsx +34 -0
  345. package/templates/apps/web/content/en/blog/index.mdx +15 -0
  346. package/templates/apps/web/content/en/blog/launching-ailk.mdx +20 -0
  347. package/templates/apps/web/content/en/docs/getting-started.mdx +30 -0
  348. package/templates/apps/web/i18n/request.ts +36 -0
  349. package/templates/apps/web/jest.config.cjs +100 -0
  350. package/templates/apps/web/lib/__mocks__/next-mdx-remote-rsc.js +12 -0
  351. package/templates/apps/web/lib/__mocks__/source-server.js +31 -0
  352. package/templates/apps/web/lib/__tests__/og.test.ts +65 -0
  353. package/templates/apps/web/lib/auth-env.ts +66 -0
  354. package/templates/apps/web/lib/blog-list-data.ts +95 -0
  355. package/templates/apps/web/lib/blog-post-data.ts +79 -0
  356. package/templates/apps/web/lib/nav-config.ts +13 -0
  357. package/templates/apps/web/lib/observability/better-stack.ts +27 -0
  358. package/templates/apps/web/lib/og.ts +23 -0
  359. package/templates/apps/web/lib/prisma-for-auth.ts +34 -0
  360. package/templates/apps/web/lib/source.ts +34 -0
  361. package/templates/apps/web/messages/en.json +131 -0
  362. package/templates/apps/web/middleware.ts +80 -0
  363. package/templates/apps/web/next.config.mjs +167 -0
  364. package/templates/apps/web/package.json +62 -0
  365. package/templates/apps/web/public/.well-known/security.txt +17 -0
  366. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  367. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  368. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  369. package/templates/apps/web/public/favicon-16x16.png +0 -0
  370. package/templates/apps/web/public/favicon-32x32.png +0 -0
  371. package/templates/apps/web/public/favicon.ico +0 -0
  372. package/templates/apps/web/public/favicon.svg +4 -0
  373. package/templates/apps/web/public/logo-dark.svg +21 -0
  374. package/templates/apps/web/public/logo-light.svg +21 -0
  375. package/templates/apps/web/public/logomark-dark.svg +4 -0
  376. package/templates/apps/web/public/logomark-light.svg +4 -0
  377. package/templates/apps/web/public/og-default.png +0 -0
  378. package/templates/apps/web/public/site.webmanifest +19 -0
  379. package/templates/apps/web/scripts/assert-content-bundled.ts +55 -0
  380. package/templates/apps/web/security-headers.config.ts +132 -0
  381. package/templates/apps/web/sentry.client.config.ts +27 -0
  382. package/templates/apps/web/sentry.edge.config.ts +15 -0
  383. package/templates/apps/web/sentry.server.config.ts +20 -0
  384. package/templates/apps/web/source.config.ts +78 -0
  385. package/templates/apps/web/src/components/SiteNav.tsx +21 -0
  386. package/templates/apps/web/src/lib/routes.ts +37 -0
  387. package/templates/apps/web/tsconfig.json +35 -0
  388. package/templates/apps/web/types/messages.d.ts +19 -0
  389. package/templates/content/.gitkeep +0 -0
  390. package/templates/content/CLAUDE.md +85 -0
  391. package/templates/content/_site.mdx +38 -0
  392. package/templates/content/ar/HomePage/home.mdx +123 -0
  393. package/templates/content/ar/_site.mdx +35 -0
  394. package/templates/content/de/_site.mdx +29 -0
  395. package/templates/content/en/.gitkeep +0 -0
  396. package/templates/content/en/AboutPage/about.mdx +19 -0
  397. package/templates/content/en/AboutPage/about.schema.json +82 -0
  398. package/templates/content/en/BookCall/book-call.mdx +19 -0
  399. package/templates/content/en/BookCall/book-call.schema.json +121 -0
  400. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +20 -0
  401. package/templates/content/en/CareersList/careers.mdx +24 -0
  402. package/templates/content/en/CaseStudiesList/case-studies.mdx +22 -0
  403. package/templates/content/en/CaseStudiesList/showcase.mdx +8 -0
  404. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +32 -0
  405. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +41 -0
  406. package/templates/content/en/ClientsList/clients.mdx +38 -0
  407. package/templates/content/en/ContactPage/contact.mdx +19 -0
  408. package/templates/content/en/ContactPage/contact.schema.json +100 -0
  409. package/templates/content/en/ContactPoint/primary.mdx +14 -0
  410. package/templates/content/en/Course/aeo-fundamentals.mdx +37 -0
  411. package/templates/content/en/Course/schema-builder-workshop.mdx +62 -0
  412. package/templates/content/en/CoursesList/courses.mdx +22 -0
  413. package/templates/content/en/DownloadsList/downloads.mdx +22 -0
  414. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +39 -0
  415. package/templates/content/en/Event/ailk-launch-day-2026.mdx +35 -0
  416. package/templates/content/en/EventsList/events.mdx +26 -0
  417. package/templates/content/en/FAQPage/faq.mdx +420 -0
  418. package/templates/content/en/FAQPage/faq.schema.json +231 -0
  419. package/templates/content/en/GlossaryList/glossary.mdx +19 -0
  420. package/templates/content/en/GlossaryList/glossary.schema.json +300 -0
  421. package/templates/content/en/HomePage/home.mdx +312 -0
  422. package/templates/content/en/HomePage/home.schema.json +88 -0
  423. package/templates/content/en/HomePage/launch-service.mdx +475 -0
  424. package/templates/content/en/HomePage/launch-service.schema.json +138 -0
  425. package/templates/content/en/HowTo/add-mdx-content.mdx +38 -0
  426. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +35 -0
  427. package/templates/content/en/HowTo/set-up-json-ld.mdx +36 -0
  428. package/templates/content/en/Integration/github-actions.mdx +31 -0
  429. package/templates/content/en/Integration/openai-api.mdx +26 -0
  430. package/templates/content/en/Integration/vercel.mdx +32 -0
  431. package/templates/content/en/IntegrationsList/integrations.mdx +22 -0
  432. package/templates/content/en/JobPosting/content-strategist.mdx +46 -0
  433. package/templates/content/en/JobPosting/developer-advocate.mdx +47 -0
  434. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +57 -0
  435. package/templates/content/en/LandingPage/aeo-audit.mdx +19 -0
  436. package/templates/content/en/LandingPage/aeo-audit.schema.json +92 -0
  437. package/templates/content/en/LandingPage/agency-waitlist.mdx +340 -0
  438. package/templates/content/en/LandingPage/agency-waitlist.schema.json +152 -0
  439. package/templates/content/en/LandingPage/developers.mdx +19 -0
  440. package/templates/content/en/LandingPage/developers.schema.json +109 -0
  441. package/templates/content/en/LandingPage/for-agencies.mdx +390 -0
  442. package/templates/content/en/LandingPage/for-agencies.schema.json +107 -0
  443. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +417 -0
  444. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +105 -0
  445. package/templates/content/en/LandingPage/for-businesses.mdx +19 -0
  446. package/templates/content/en/LandingPage/for-businesses.schema.json +96 -0
  447. package/templates/content/en/LandingPage/platform.mdx +369 -0
  448. package/templates/content/en/LandingPage/platform.schema.json +166 -0
  449. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +19 -0
  450. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +102 -0
  451. package/templates/content/en/LandingPage/vs-wordpress.mdx +19 -0
  452. package/templates/content/en/LandingPage/vs-wordpress.schema.json +53 -0
  453. package/templates/content/en/NewsletterSignup/newsletter.mdx +236 -0
  454. package/templates/content/en/NewsletterSignup/newsletter.schema.json +91 -0
  455. package/templates/content/en/Organization/site-owner.mdx +16 -0
  456. package/templates/content/en/Partner/prisma.mdx +31 -0
  457. package/templates/content/en/Partner/supabase.mdx +30 -0
  458. package/templates/content/en/Partner/vercel.mdx +28 -0
  459. package/templates/content/en/PartnersList/partners.mdx +22 -0
  460. package/templates/content/en/Person/founder.mdx +18 -0
  461. package/templates/content/en/Pricing/pricing.mdx +538 -0
  462. package/templates/content/en/Pricing/pricing.schema.json +209 -0
  463. package/templates/content/en/PrivacyPage/privacy.mdx +165 -0
  464. package/templates/content/en/PrivacyPage/privacy.schema.json +64 -0
  465. package/templates/content/en/Product/ailk.mdx +19 -0
  466. package/templates/content/en/Product/ailk.schema.json +94 -0
  467. package/templates/content/en/Product/api-launch-kit.mdx +26 -0
  468. package/templates/content/en/Product/launch-kit.mdx +28 -0
  469. package/templates/content/en/ProductsList/products.mdx +26 -0
  470. package/templates/content/en/Project/ailk-phase-2.mdx +54 -0
  471. package/templates/content/en/ProjectsList/projects.mdx +25 -0
  472. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +27 -0
  473. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +28 -0
  474. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +39 -0
  475. package/templates/content/en/ResourcesHub/resources.mdx +22 -0
  476. package/templates/content/en/Service/consulting.mdx +22 -0
  477. package/templates/content/en/Service/implementation.mdx +30 -0
  478. package/templates/content/en/Service/launch-service.mdx +232 -0
  479. package/templates/content/en/Service/launch.mdx +228 -0
  480. package/templates/content/en/ServicesList/services.mdx +84 -0
  481. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +44 -0
  482. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +47 -0
  483. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +49 -0
  484. package/templates/content/en/TeamList/team.mdx +63 -0
  485. package/templates/content/en/TeamMember/founder.mdx +72 -0
  486. package/templates/content/en/TermsPage/terms.mdx +19 -0
  487. package/templates/content/en/TermsPage/terms.schema.json +99 -0
  488. package/templates/content/en/TestimonialsList/testimonials.mdx +44 -0
  489. package/templates/content/en/UseCasesList/use-cases.mdx +30 -0
  490. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +27 -0
  491. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +27 -0
  492. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +31 -0
  493. package/templates/content/en-XA/AboutPage/about.mdx +8 -0
  494. package/templates/content/en-XA/FAQPage/faq.mdx +8 -0
  495. package/templates/content/en-XA/HomePage/home.mdx +8 -0
  496. package/templates/content/es/_site.mdx +30 -0
  497. package/templates/content/fr/_site.mdx +30 -0
  498. package/templates/content/ja/_site.mdx +30 -0
  499. package/templates/content/pt-BR/AboutPage/about.mdx +8 -0
  500. package/templates/content/pt-BR/FAQPage/faq.mdx +8 -0
  501. package/templates/content/pt-BR/HomePage/home.mdx +23 -0
  502. package/templates/database/CHANGELOG.md +5 -0
  503. package/templates/database/README.md +234 -0
  504. package/templates/database/__tests__/user-cascade.test.ts +125 -0
  505. package/templates/database/auth/.gitkeep +0 -0
  506. package/templates/database/config/.gitkeep +0 -0
  507. package/templates/database/content/.gitkeep +0 -0
  508. package/templates/database/inbox/.gitkeep +0 -0
  509. package/templates/database/inbox/README.md +3 -0
  510. package/templates/database/inbox/__tests__/schema.test.ts +40 -0
  511. package/templates/database/inbox/schema.prisma +52 -0
  512. package/templates/database/jest.config.cjs +29 -0
  513. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +328 -0
  514. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +100 -0
  515. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +28 -0
  516. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +25 -0
  517. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +42 -0
  518. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +67 -0
  519. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +21 -0
  520. package/templates/database/migrations/migration_lock.toml +3 -0
  521. package/templates/database/ops/.gitkeep +0 -0
  522. package/templates/database/package.json +45 -0
  523. package/templates/database/prisma.config.ts +20 -0
  524. package/templates/database/schema.prisma +487 -0
  525. package/templates/database/scripts/db-generate-locked.sh +80 -0
  526. package/templates/database/seed-test.ts +85 -0
  527. package/templates/database/tsconfig.build.json +16 -0
  528. package/templates/database/tsconfig.json +9 -0
  529. package/templates/package.json +125 -0
  530. package/templates/pnpm-workspace.yaml +6 -0
  531. package/templates/project.yaml +59 -0
  532. package/templates/tsconfig.base.json +18 -0
  533. package/templates/tsconfig.json +45 -0
@@ -0,0 +1,292 @@
1
+ /**
2
+ * @file rate-limit.test.ts
3
+ * Unit tests for the per-IP rate limit middleware (B5).
4
+ *
5
+ * Test runner: Jest (repo convention — jest.config.cjs uses ts-jest).
6
+ * Time control: jest.spyOn(Date, 'now') for the window-rolls case. We
7
+ * intentionally avoid jest.useFakeTimers() because it intercepts setImmediate,
8
+ * which Fastify's avvio plugin system uses during app.ready() — causing all
9
+ * tests to hang. Controlling Date.now() directly is sufficient since the
10
+ * middleware's window logic only calls Date.now().
11
+ * IP mocking: x-forwarded-for header on a trustProxy:true test server.
12
+ *
13
+ * Spec: docs/internal/specs/draft/ailk-mcp-mvp-pb-b5-rate-limit-spec.md
14
+ */
15
+
16
+ import Fastify from "fastify";
17
+ import { rateLimitMiddleware, __resetBuckets } from "../rate-limit.js";
18
+
19
+ function buildApp(opts: { perIpPerMinute?: number; windowMs?: number } = {}) {
20
+ const app = Fastify({ trustProxy: true });
21
+ app.register(rateLimitMiddleware, opts);
22
+ app.get("/test", async () => ({ ok: true }));
23
+ return app;
24
+ }
25
+
26
+ describe("per-IP rate limit middleware", () => {
27
+ let app: ReturnType<typeof buildApp>;
28
+
29
+ beforeEach(async () => {
30
+ __resetBuckets();
31
+ });
32
+
33
+ afterEach(async () => {
34
+ await app.close();
35
+ });
36
+
37
+ it("allows requests within the per-minute budget (5 under default)", async () => {
38
+ app = buildApp({ perIpPerMinute: 10 });
39
+ await app.ready();
40
+
41
+ for (let i = 0; i < 5; i++) {
42
+ const res = await app.inject({
43
+ method: "GET",
44
+ url: "/test",
45
+ headers: { "x-forwarded-for": "1.2.3.4" },
46
+ });
47
+ expect(res.statusCode).toBe(200);
48
+ }
49
+ });
50
+
51
+ it("returns 429 on the 11th request within the window", async () => {
52
+ app = buildApp({ perIpPerMinute: 10 });
53
+ await app.ready();
54
+
55
+ for (let i = 0; i < 10; i++) {
56
+ const res = await app.inject({
57
+ method: "GET",
58
+ url: "/test",
59
+ headers: { "x-forwarded-for": "1.2.3.4" },
60
+ });
61
+ expect(res.statusCode).toBe(200);
62
+ }
63
+
64
+ const overLimit = await app.inject({
65
+ method: "GET",
66
+ url: "/test",
67
+ headers: { "x-forwarded-for": "1.2.3.4" },
68
+ });
69
+ expect(overLimit.statusCode).toBe(429);
70
+ const body = JSON.parse(overLimit.body);
71
+ expect(body).toMatchObject({ error: "rate_limited" });
72
+ expect(typeof body.retryAfterSeconds).toBe("number");
73
+ });
74
+
75
+ it("resets the budget after the window expires", async () => {
76
+ // windowMs is passed explicitly so the spy's return value uses the same window the middleware sees.
77
+ const windowMs = 60_000;
78
+ app = buildApp({ perIpPerMinute: 2, windowMs });
79
+ await app.ready();
80
+
81
+ const origin = Date.now();
82
+ const dateSpy = jest.spyOn(Date, "now").mockReturnValue(origin);
83
+
84
+ try {
85
+ // Exhaust the budget.
86
+ for (let i = 0; i < 2; i++) {
87
+ await app.inject({
88
+ method: "GET",
89
+ url: "/test",
90
+ headers: { "x-forwarded-for": "1.2.3.4" },
91
+ });
92
+ }
93
+
94
+ // Advance virtual time past the window.
95
+ dateSpy.mockReturnValue(origin + windowMs + 1);
96
+
97
+ // First request in the new window should succeed.
98
+ const afterRoll = await app.inject({
99
+ method: "GET",
100
+ url: "/test",
101
+ headers: { "x-forwarded-for": "1.2.3.4" },
102
+ });
103
+ expect(afterRoll.statusCode).toBe(200);
104
+ } finally {
105
+ dateSpy.mockRestore();
106
+ }
107
+ });
108
+
109
+ it("includes Retry-After header <= 60 on 429 responses", async () => {
110
+ app = buildApp({ perIpPerMinute: 1 });
111
+ await app.ready();
112
+
113
+ // Use the budget.
114
+ await app.inject({
115
+ method: "GET",
116
+ url: "/test",
117
+ headers: { "x-forwarded-for": "10.0.0.1" },
118
+ });
119
+
120
+ // Trigger 429.
121
+ const over = await app.inject({
122
+ method: "GET",
123
+ url: "/test",
124
+ headers: { "x-forwarded-for": "10.0.0.1" },
125
+ });
126
+
127
+ expect(over.statusCode).toBe(429);
128
+ const retryAfter = Number(over.headers["retry-after"]);
129
+ expect(Number.isInteger(retryAfter)).toBe(true);
130
+ expect(retryAfter).toBeGreaterThanOrEqual(1);
131
+ expect(retryAfter).toBeLessThanOrEqual(60);
132
+ });
133
+
134
+ it("tracks independent buckets per IP", async () => {
135
+ app = buildApp({ perIpPerMinute: 2 });
136
+ await app.ready();
137
+
138
+ // Exhaust IP A.
139
+ for (let i = 0; i < 2; i++) {
140
+ await app.inject({
141
+ method: "GET",
142
+ url: "/test",
143
+ headers: { "x-forwarded-for": "192.168.1.1" },
144
+ });
145
+ }
146
+ const ipA429 = await app.inject({
147
+ method: "GET",
148
+ url: "/test",
149
+ headers: { "x-forwarded-for": "192.168.1.1" },
150
+ });
151
+ expect(ipA429.statusCode).toBe(429);
152
+
153
+ // IP B should still have a fresh budget.
154
+ const ipB = await app.inject({
155
+ method: "GET",
156
+ url: "/test",
157
+ headers: { "x-forwarded-for": "192.168.1.2" },
158
+ });
159
+ expect(ipB.statusCode).toBe(200);
160
+ });
161
+
162
+ it("respects custom perIpPerMinute option over env/default", async () => {
163
+ app = buildApp({ perIpPerMinute: 3 });
164
+ await app.ready();
165
+
166
+ // 3 requests should succeed.
167
+ for (let i = 0; i < 3; i++) {
168
+ const res = await app.inject({
169
+ method: "GET",
170
+ url: "/test",
171
+ headers: { "x-forwarded-for": "5.5.5.5" },
172
+ });
173
+ expect(res.statusCode).toBe(200);
174
+ }
175
+
176
+ // 4th exceeds the custom limit of 3.
177
+ const fourth = await app.inject({
178
+ method: "GET",
179
+ url: "/test",
180
+ headers: { "x-forwarded-for": "5.5.5.5" },
181
+ });
182
+ expect(fourth.statusCode).toBe(429);
183
+ });
184
+
185
+ it("reads perIpPerMinute from AILK_RATE_LIMIT_PER_IP_PER_MINUTE env var when option is absent", async () => {
186
+ // Cover `Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) || 10` left-side truthy arm
187
+ // Build the app without a perIpPerMinute option so the env var path is taken
188
+ const prev = process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE;
189
+ process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE = "2";
190
+ try {
191
+ app = buildApp({}); // no perIpPerMinute — env var drives the limit
192
+ await app.ready();
193
+
194
+ // 2 requests should succeed (env-configured limit).
195
+ for (let i = 0; i < 2; i++) {
196
+ const res = await app.inject({
197
+ method: "GET",
198
+ url: "/test",
199
+ headers: { "x-forwarded-for": "7.7.7.7" },
200
+ });
201
+ expect(res.statusCode).toBe(200);
202
+ }
203
+
204
+ // 3rd request exceeds the env-configured limit of 2.
205
+ const third = await app.inject({
206
+ method: "GET",
207
+ url: "/test",
208
+ headers: { "x-forwarded-for": "7.7.7.7" },
209
+ });
210
+ expect(third.statusCode).toBe(429);
211
+ } finally {
212
+ if (prev === undefined) {
213
+ delete process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE;
214
+ } else {
215
+ process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE = prev;
216
+ }
217
+ }
218
+ });
219
+
220
+ it("evicts stale buckets when cleanup is triggered on 429 with a stale IP in store", async () => {
221
+ // Cover the stale-bucket delete arm inside evictStaleBuckets (line 54)
222
+ // Scenario: IP A exhausts budget at t=0, time advances past window, IP B hits 429 — cleanup evicts A's stale bucket
223
+ const windowMs = 60_000;
224
+ app = buildApp({ perIpPerMinute: 1, windowMs });
225
+ await app.ready();
226
+
227
+ const origin = Date.now();
228
+ const dateSpy = jest.spyOn(Date, "now").mockReturnValue(origin);
229
+
230
+ try {
231
+ // Exhaust IP A's budget at t=0.
232
+ await app.inject({
233
+ method: "GET",
234
+ url: "/test",
235
+ headers: { "x-forwarded-for": "11.0.0.1" },
236
+ });
237
+
238
+ // Advance virtual time past the window so IP A's bucket is now stale.
239
+ dateSpy.mockReturnValue(origin + windowMs + 1);
240
+
241
+ // Exhaust IP B's budget (fresh window).
242
+ await app.inject({
243
+ method: "GET",
244
+ url: "/test",
245
+ headers: { "x-forwarded-for": "11.0.0.2" },
246
+ });
247
+
248
+ // IP B's 2nd request triggers 429 → calls evictStaleBuckets → deletes IP A's stale bucket.
249
+ const res = await app.inject({
250
+ method: "GET",
251
+ url: "/test",
252
+ headers: { "x-forwarded-for": "11.0.0.2" },
253
+ });
254
+
255
+ expect(res.statusCode).toBe(429);
256
+ // Cleanup ran — IP A's stale bucket was deleted. IP A can now make fresh requests.
257
+ const resA = await app.inject({
258
+ method: "GET",
259
+ url: "/test",
260
+ headers: { "x-forwarded-for": "11.0.0.1" },
261
+ });
262
+ expect(resA.statusCode).toBe(200);
263
+ } finally {
264
+ dateSpy.mockRestore();
265
+ }
266
+ });
267
+
268
+ it("triggers periodic cleanup after 100 requests in a single window", async () => {
269
+ // Cover `requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS` true arm (line 93)
270
+ // 100 requests from distinct IPs fill the bucket store; the 101st triggers eviction
271
+ app = buildApp({ perIpPerMinute: 200 }); // high limit so no 429 for first 100 requests
272
+ await app.ready();
273
+
274
+ // Send 100 requests from distinct IPs to advance requestsSinceLastClean to 100.
275
+ for (let i = 0; i < 100; i++) {
276
+ await app.inject({
277
+ method: "GET",
278
+ url: "/test",
279
+ headers: { "x-forwarded-for": `10.${Math.floor(i / 255)}.${i % 255}.1` },
280
+ });
281
+ }
282
+
283
+ // The 101st request (any IP) crosses the CLEAN_INTERVAL_REQUESTS threshold.
284
+ const res = await app.inject({
285
+ method: "GET",
286
+ url: "/test",
287
+ headers: { "x-forwarded-for": "10.1.0.99" },
288
+ });
289
+ // Cleanup fired — the route still responds normally.
290
+ expect(res.statusCode).toBe(200);
291
+ });
292
+ });
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Dual-mode authentication preHandler for apps/api (#587).
3
+ *
4
+ * Decorates `request.userId` on authenticated requests, or returns 401.
5
+ * Two modes, evaluated in order:
6
+ *
7
+ * 1. Cookie path — Better Auth session cookie present and valid.
8
+ * Looked up directly against the `Session` table (the row Better Auth's
9
+ * database adapter creates on sign-in). Cookie wins when both modes are
10
+ * present (locked by brief decision OQ-D / spec §3 "Both present").
11
+ * Non-GET requests with a cookie are required to carry a same-origin
12
+ * Origin header — the lightweight CSRF mechanism agreed in OQ-D. Bearer-
13
+ * authenticated callers skip the origin check (non-browser flow).
14
+ *
15
+ * 2. Bearer path — Authorization: Bearer <token>. The token is sha256-hashed
16
+ * and matched against `ApiToken.token` (stored as hash). Revoked / expired
17
+ * tokens fail closed; lastUsedAt is bumped on a successful hit.
18
+ *
19
+ * Decision matrix (per spec §3):
20
+ * - Cookie valid → cookie path wins. Bearer ignored.
21
+ * - Cookie absent, bearer valid → bearer path; CSRF skipped.
22
+ * - Both modes invalid → 401 unauthorized.
23
+ * - Cookie expired + bearer valid → 401 (no silent fallback).
24
+ * - Cookie absent, bearer malformed → 401 (no silent fallback).
25
+ *
26
+ * Public routes (health, content, leads, webhooks, openapi) are NOT registered
27
+ * with this preHandler — server.ts narrows scope via a sub-plugin.
28
+ */
29
+ import { createHash } from "node:crypto";
30
+
31
+ import { getClock } from "@working-theory/common";
32
+ import type {
33
+ FastifyPluginCallback,
34
+ FastifyReply,
35
+ FastifyRequest,
36
+ } from "fastify";
37
+
38
+ import { prisma } from "../lib/prisma.js";
39
+
40
+ /**
41
+ * Better Auth's default cookie names. Both are tried in order so a deployment
42
+ * running over HTTP locally and HTTPS in production reads the same code path.
43
+ */
44
+ const COOKIE_NAMES = [
45
+ "better-auth.session_token",
46
+ "__Secure-better-auth.session_token",
47
+ ] as const;
48
+
49
+ const UNAUTHORIZED = { error: "unauthorized" } as const;
50
+
51
+ declare module "fastify" {
52
+ interface FastifyRequest {
53
+ userId?: string;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Hash an opaque bearer-token string the same way `tokens.ts` does at create
59
+ * time. Centralised here so the lookup contract stays identical.
60
+ */
61
+ function hashBearer(token: string): string {
62
+ return createHash("sha256").update(token).digest("hex");
63
+ }
64
+
65
+ /**
66
+ * Parse the request's Cookie header for the first Better Auth session-token
67
+ * variant present. Returns the cookie value (decoded) or undefined.
68
+ *
69
+ * Cookie parsing intentionally avoids decoding `+` as space — session tokens
70
+ * are URL-safe and don't contain `+`. Use decodeURIComponent only on the value
71
+ * to handle the rare case Better Auth percent-encodes the token.
72
+ */
73
+ function readSessionCookie(request: FastifyRequest): string | undefined {
74
+ const raw = request.headers.cookie;
75
+ if (!raw) return undefined;
76
+ for (const part of raw.split(";")) {
77
+ const eq = part.indexOf("=");
78
+ if (eq < 0) continue;
79
+ const name = part.slice(0, eq).trim();
80
+ if (!COOKIE_NAMES.includes(name as (typeof COOKIE_NAMES)[number])) continue;
81
+ const value = part.slice(eq + 1).trim();
82
+ if (!value) continue;
83
+ try {
84
+ // Better Auth signs the cookie as <token>.<signature>; the dual-mode
85
+ // preHandler only needs the token portion to look up the Session row.
86
+ // Strip the signature if present.
87
+ const decoded = decodeURIComponent(value);
88
+ const dot = decoded.indexOf(".");
89
+ return dot > 0 ? decoded.slice(0, dot) : decoded;
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ }
94
+ return undefined;
95
+ }
96
+
97
+ function readBearer(request: FastifyRequest): string | undefined {
98
+ const auth = request.headers.authorization;
99
+ if (!auth) return undefined;
100
+ const match = /^Bearer\s+(\S+)$/i.exec(auth);
101
+ return match ? match[1] : undefined;
102
+ }
103
+
104
+ /**
105
+ * Same-origin CSRF check for cookie-authenticated mutating requests.
106
+ * Returns true if the request is safe (GET/HEAD/OPTIONS) or the Origin header
107
+ * matches the configured frontend origin. False otherwise.
108
+ */
109
+ function isOriginAllowed(
110
+ request: FastifyRequest,
111
+ frontendOrigin: string | undefined,
112
+ ): boolean {
113
+ const method = request.method.toUpperCase();
114
+ if (method === "GET" || method === "HEAD" || method === "OPTIONS")
115
+ return true;
116
+ if (!frontendOrigin) {
117
+ // No FRONTEND_URL configured → accept any origin. The single-tenant OSS
118
+ // default trusts the deployment to set FRONTEND_URL; mis-configuration
119
+ // is a deploy-time concern, not a runtime auth failure.
120
+ return true;
121
+ }
122
+ const origin = request.headers.origin;
123
+ if (!origin) return false;
124
+ return origin === frontendOrigin;
125
+ }
126
+
127
+ type SessionRow = {
128
+ userId: string;
129
+ expiresAt: Date;
130
+ };
131
+
132
+ async function resolveCookieSession(
133
+ cookieToken: string,
134
+ ): Promise<SessionRow | undefined> {
135
+ const row = await prisma.session.findUnique({
136
+ where: { token: cookieToken },
137
+ select: { userId: true, expiresAt: true },
138
+ });
139
+ if (!row) return undefined;
140
+ return row;
141
+ }
142
+
143
+ type BearerRow = {
144
+ userId: string;
145
+ expiresAt: Date | null;
146
+ revokedAt: Date | null;
147
+ id: string;
148
+ };
149
+
150
+ async function resolveBearerToken(
151
+ bearer: string,
152
+ ): Promise<BearerRow | undefined> {
153
+ const hash = hashBearer(bearer);
154
+ const row = await prisma.apiToken.findUnique({
155
+ where: { token: hash },
156
+ select: { id: true, userId: true, expiresAt: true, revokedAt: true },
157
+ });
158
+ return row ?? undefined;
159
+ }
160
+
161
+ export type AuthPreHandlerOptions = {
162
+ /** Configured frontend origin used for the CSRF same-origin check. */
163
+ frontendOrigin?: string | undefined;
164
+ };
165
+
166
+ /**
167
+ * Fastify preHandler implementing the dual-mode contract.
168
+ *
169
+ * Exported standalone so route-test suites can register it directly without
170
+ * spinning up the full server module.
171
+ */
172
+ export function makeAuthPreHandler(opts: AuthPreHandlerOptions = {}) {
173
+ const { frontendOrigin } = opts;
174
+ return async function authPreHandler(
175
+ request: FastifyRequest,
176
+ reply: FastifyReply,
177
+ ): Promise<void> {
178
+ const now = getClock().now();
179
+ const cookieToken = readSessionCookie(request);
180
+
181
+ if (cookieToken) {
182
+ const session = await resolveCookieSession(cookieToken).catch(
183
+ () => undefined,
184
+ );
185
+ if (session && session.expiresAt.getTime() > now) {
186
+ if (!isOriginAllowed(request, frontendOrigin)) {
187
+ reply.code(401).send(UNAUTHORIZED);
188
+ return;
189
+ }
190
+ request.userId = session.userId;
191
+ return;
192
+ }
193
+ // Cookie present but invalid / expired → 401 (no silent bearer fallback).
194
+ reply.code(401).send(UNAUTHORIZED);
195
+ return;
196
+ }
197
+
198
+ const bearer = readBearer(request);
199
+ if (bearer) {
200
+ const row = await resolveBearerToken(bearer).catch(() => undefined);
201
+ if (!row) {
202
+ reply.code(401).send(UNAUTHORIZED);
203
+ return;
204
+ }
205
+ if (row.revokedAt) {
206
+ reply.code(401).send(UNAUTHORIZED);
207
+ return;
208
+ }
209
+ if (row.expiresAt && row.expiresAt.getTime() <= now) {
210
+ reply.code(401).send(UNAUTHORIZED);
211
+ return;
212
+ }
213
+ request.userId = row.userId;
214
+ // Best-effort: bump lastUsedAt. Failure here MUST NOT 401 — the caller
215
+ // is already authenticated. Swallow the error and continue.
216
+ prisma.apiToken
217
+ .update({ where: { id: row.id }, data: { lastUsedAt: new Date(now) } })
218
+ .catch(() => undefined);
219
+ return;
220
+ }
221
+
222
+ // Neither present.
223
+ reply.code(401).send(UNAUTHORIZED);
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Plugin form — exposes the preHandler via Fastify's `addHook`. The
229
+ * encapsulation override (Symbol.for("skip-override")) lets the hook
230
+ * propagate to sibling routes registered in the SAME parent scope after the
231
+ * plugin — matching the rate-limit.ts pattern. Without it, the hook would
232
+ * only fire for routes registered INSIDE the plugin, which is not the calling
233
+ * convention (server.ts registers the plugin first, then sibling routes).
234
+ */
235
+ export const authPreHandlerPlugin: FastifyPluginCallback<
236
+ AuthPreHandlerOptions
237
+ > = (fastify, opts, done) => {
238
+ const handler = makeAuthPreHandler(opts);
239
+ fastify.decorateRequest("userId", undefined);
240
+ fastify.addHook("preHandler", handler);
241
+ done();
242
+ };
243
+
244
+ (authPreHandlerPlugin as FastifyPluginCallback & { [key: symbol]: boolean })[
245
+ Symbol.for("skip-override")
246
+ ] = true;
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Per-IP rate limit middleware for anonymous write routes.
3
+ *
4
+ * SINGLE-REPLICA CAVEAT: This module uses an in-process Map to store request
5
+ * counts. Each Fastify instance maintains its own bucket state. In a
6
+ * multi-replica deployment (e.g. 3 Vercel functions), an attacker can issue
7
+ * up to N × perIpPerMinute requests per minute per IP, where N is the replica
8
+ * count. For OSS v1 (single-replica), this is acceptable. A Redis-backed swap
9
+ * is tracked in the follow-up issue "Phase 6+ — Redis-backed rate limit for
10
+ * multi-replica deploys."
11
+ *
12
+ * Cleanup strategy: evict stale buckets on every 429 emission and every 100
13
+ * allowed requests (bounded, O(n) over bucket count — negligible for typical
14
+ * IP cardinality). No setInterval is started until the middleware is registered,
15
+ * keeping the module import-side-effect-free.
16
+ *
17
+ * Trust-proxy boundary: this middleware reads req.ip, which Fastify populates
18
+ * from the socket address and any trusted proxy headers (X-Forwarded-For) per
19
+ * the server's own trustProxy config. This middleware does NOT parse
20
+ * X-Forwarded-For itself.
21
+ */
22
+
23
+ import { getClock } from "@working-theory/common";
24
+ import type {
25
+ FastifyRequest,
26
+ FastifyReply,
27
+ FastifyPluginCallback,
28
+ } from "fastify";
29
+
30
+ export type RateLimitOptions = {
31
+ /** Requests allowed per IP per window. Default: AILK_RATE_LIMIT_PER_IP_PER_MINUTE env var ?? 10 */
32
+ perIpPerMinute?: number;
33
+ /** Window duration in milliseconds. Default: 60_000 */
34
+ windowMs?: number;
35
+ /** Extract the IP key from a request. Default: req.ip */
36
+ ipFromRequest?: (req: FastifyRequest) => string;
37
+ };
38
+
39
+ type Bucket = {
40
+ count: number;
41
+ windowStartMs: number;
42
+ };
43
+
44
+ const RATE_LIMITED = "rate_limited" as const;
45
+
46
+ // Module-scoped bucket store. One entry per unique IP.
47
+ const buckets = new Map<string, Bucket>();
48
+
49
+ // Request counter used to trigger periodic cleanup.
50
+ let requestsSinceLastClean = 0;
51
+ const CLEAN_INTERVAL_REQUESTS = 100;
52
+
53
+ function evictStaleBuckets(now: number, windowMs: number): void {
54
+ for (const [ip, bucket] of buckets) {
55
+ if (now - bucket.windowStartMs >= windowMs) {
56
+ buckets.delete(ip);
57
+ }
58
+ }
59
+ requestsSinceLastClean = 0;
60
+ }
61
+
62
+ /**
63
+ * Test-only reset. Clears all bucket state between test cases so they do not
64
+ * interfere with each other. Not intended for production use.
65
+ */
66
+ export function __resetBuckets(): void {
67
+ buckets.clear();
68
+ requestsSinceLastClean = 0;
69
+ }
70
+
71
+ // Break Fastify's plugin encapsulation so the onRequest hook propagates to
72
+ // parent and sibling scopes. This is the manual equivalent of fastify-plugin's
73
+ // `skip-override` mechanism — documented in Fastify's plugin guide.
74
+ // Without this, routes registered outside this plugin's scope do not see the hook.
75
+ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
76
+ fastify,
77
+ opts,
78
+ done,
79
+ ) => {
80
+ const {
81
+ perIpPerMinute = Number(process.env.AILK_RATE_LIMIT_PER_IP_PER_MINUTE) ||
82
+ 10,
83
+ windowMs = 60_000,
84
+ ipFromRequest = (req: FastifyRequest) => req.ip,
85
+ } = opts;
86
+
87
+ fastify.addHook(
88
+ "onRequest",
89
+ async (req: FastifyRequest, reply: FastifyReply) => {
90
+ const now = getClock().now();
91
+ const ip = ipFromRequest(req);
92
+
93
+ requestsSinceLastClean += 1;
94
+ if (requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS) {
95
+ evictStaleBuckets(now, windowMs);
96
+ }
97
+
98
+ let bucket = buckets.get(ip);
99
+
100
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
101
+ bucket = { count: 0, windowStartMs: now };
102
+ buckets.set(ip, bucket);
103
+ }
104
+
105
+ bucket.count += 1;
106
+
107
+ if (bucket.count > perIpPerMinute) {
108
+ const retryAfterSeconds = Math.ceil(
109
+ (bucket.windowStartMs + windowMs - now) / 1_000,
110
+ );
111
+
112
+ // Evict stale buckets on every 429 to cap memory under abuse.
113
+ evictStaleBuckets(now, windowMs);
114
+
115
+ req.log.warn({ ip, path: req.url, count: bucket.count }, RATE_LIMITED);
116
+
117
+ return reply
118
+ .status(429)
119
+ .header("Retry-After", String(retryAfterSeconds))
120
+ .send({ error: RATE_LIMITED, retryAfterSeconds });
121
+ }
122
+ },
123
+ );
124
+
125
+ done();
126
+ };
127
+
128
+ // Skip Fastify's plugin encapsulation — hooks registered inside propagate to
129
+ // all scopes (parent + siblings), not just descendants of this plugin.
130
+ (rateLimitMiddleware as FastifyPluginCallback & { [key: symbol]: boolean })[
131
+ Symbol.for("skip-override")
132
+ ] = true;