create-ailk 0.21.0 → 0.23.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 (342) hide show
  1. package/component-catalog.json +186 -8
  2. package/component-catalog.md +9 -7
  3. package/dist/add-module.js +20 -14
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +12 -3
  8. package/dist/derive-architecture.js +20 -8
  9. package/dist/design-package.js +7 -2
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +5 -5
  12. package/dist/lib/npm-auth.d.ts +9 -0
  13. package/dist/lib/npm-auth.js +48 -0
  14. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  15. package/dist/lib/scaffold-render-sweep.js +5 -8
  16. package/dist/module-architecture.js +99 -9
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/programmatic.js +19 -5
  20. package/dist/seed-flows.d.ts +4 -1
  21. package/dist/seed-flows.js +50 -4
  22. package/dist/site-chrome.d.ts +8 -0
  23. package/dist/site-chrome.js +17 -1
  24. package/dist/spoke-checkout.d.ts +65 -41
  25. package/dist/spoke-checkout.js +208 -430
  26. package/dist/starter-grid.d.ts +0 -3
  27. package/dist/starter-grid.js +24 -14
  28. package/dist/surfaces.js +52 -26
  29. package/dist/sync-routes.d.ts +38 -1
  30. package/dist/sync-routes.js +187 -13
  31. package/dist/template-record.d.ts +4 -3
  32. package/dist/template-record.js +8 -4
  33. package/dist/topology.js +25 -1
  34. package/dist/update.js +15 -0
  35. package/package.json +3 -3
  36. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  37. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  38. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  39. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  41. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  43. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  48. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  49. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  50. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  51. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  52. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  53. package/templates/.env.example +9 -7
  54. package/templates/.github/workflows/ci.yml +13 -0
  55. package/templates/README.md +1 -0
  56. package/templates/_gitignore +4 -0
  57. package/templates/apps/api/.env.example +27 -0
  58. package/templates/apps/api/CLAUDE.md +87 -17
  59. package/templates/apps/api/package.json +1 -0
  60. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  61. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  62. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  63. package/templates/apps/api/src/config/index.ts +109 -0
  64. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  65. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  66. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  67. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  68. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  69. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  70. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  71. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  72. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  73. package/templates/apps/api/src/lib/stripe.ts +74 -4
  74. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  75. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  76. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  77. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  78. package/templates/apps/api/src/middleware/auth.ts +13 -6
  79. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  80. package/templates/apps/api/src/openapi/spec.ts +33 -3
  81. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  82. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  83. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  84. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  85. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  86. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  87. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  88. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  89. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  90. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  91. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  92. package/templates/apps/api/src/routes/consent/account.ts +268 -0
  93. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  94. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  95. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  96. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  97. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  98. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  99. package/templates/apps/api/src/routes/content/index.ts +16 -2
  100. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  101. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  102. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  103. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  104. package/templates/apps/api/src/routes/leads/index.ts +164 -7
  105. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  106. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  111. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  112. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  113. package/templates/apps/api/src/routes/webhooks/stripe.ts +245 -36
  114. package/templates/apps/api/src/server.ts +163 -46
  115. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  116. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  117. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  118. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1383 -133
  119. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  120. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  121. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  122. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  123. package/templates/apps/api/src/services/consent.ts +95 -0
  124. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  125. package/templates/apps/api/src/services/entitlements.ts +157 -1
  126. package/templates/apps/api/src/services/lead-routing.ts +431 -47
  127. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  128. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  129. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  130. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  131. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  132. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  133. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  134. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  135. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  136. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  137. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  138. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  139. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  140. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  141. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  142. package/templates/apps/api/src/services/person.ts +164 -17
  143. package/templates/apps/api/src/services/plan-offerings.ts +125 -0
  144. package/templates/apps/api/src/services/public-site-resolver.ts +27 -15
  145. package/templates/apps/mcp/CLAUDE.md +22 -14
  146. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  147. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  148. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  149. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  150. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  151. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  152. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  153. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  154. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  155. package/templates/apps/mcp/src/server.ts +5 -0
  156. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  157. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  158. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  159. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  160. package/templates/apps/web/.env.example +24 -31
  161. package/templates/apps/web/CLAUDE.md +393 -9
  162. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  163. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  164. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  165. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  166. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  167. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  168. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  169. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  170. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  171. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  172. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  173. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  174. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  176. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  177. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  178. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  179. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  183. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  184. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  185. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  186. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  187. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  188. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  189. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  190. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  191. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  192. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  193. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  194. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  195. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  196. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +295 -0
  197. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +120 -0
  198. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +23 -0
  199. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +319 -0
  200. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  201. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  202. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +141 -0
  203. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +424 -0
  204. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +72 -11
  205. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  206. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  207. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  208. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  209. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  210. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  211. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  212. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  213. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  214. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +160 -13
  215. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +466 -36
  216. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  217. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +172 -57
  218. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  219. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -60
  220. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  221. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  222. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +341 -4
  223. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  224. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  225. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  226. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  227. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  228. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  229. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  230. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  231. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  232. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  233. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  234. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  235. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  236. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  237. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  238. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  239. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  240. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  241. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  242. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  243. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  244. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  245. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  246. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  247. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  248. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  249. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  250. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  251. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  252. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  253. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  254. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  255. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  256. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  257. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  258. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  259. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  260. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  261. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  262. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  263. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  264. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  265. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  266. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  267. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  268. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  269. package/templates/apps/web/auth.ts +28 -2
  270. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  271. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  272. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  273. package/templates/apps/web/lib/__tests__/deck-page.test.ts +7 -1
  274. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  275. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  276. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  277. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  278. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +101 -25
  279. package/templates/apps/web/lib/account-invite.ts +37 -0
  280. package/templates/apps/web/lib/auth-routes.ts +36 -2
  281. package/templates/apps/web/lib/checkout-config.ts +16 -0
  282. package/templates/apps/web/lib/consent-policies.ts +22 -0
  283. package/templates/apps/web/lib/deck-page.ts +7 -0
  284. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  285. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  286. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  287. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  288. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  289. package/templates/apps/web/lib/modules.generated.ts +9 -0
  290. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  291. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  292. package/templates/apps/web/lib/posting-title.ts +17 -0
  293. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  294. package/templates/apps/web/lib/sign-up-agreements.ts +449 -0
  295. package/templates/apps/web/lib/site-chrome.ts +2 -0
  296. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  297. package/templates/apps/web/messages/en.json +71 -20
  298. package/templates/apps/web/middleware.ts +12 -2
  299. package/templates/apps/web/package.json +1 -0
  300. package/templates/apps/web/security-headers.config.ts +17 -0
  301. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  302. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  303. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  304. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  305. package/templates/content/en/BlogList/blog.mdx +4 -71
  306. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  307. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  308. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  309. package/templates/content/en/BookCall/start-project.mdx +1 -17
  310. package/templates/content/en/ContactPage/contact.mdx +6 -63
  311. package/templates/content/en/HomePage/home.mdx +3 -21
  312. package/templates/database/CHANGELOG.md +69 -0
  313. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  314. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  315. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  316. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  317. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  318. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  319. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  320. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  321. package/templates/database/__tests__/seed.test.ts +64 -1
  322. package/templates/database/auth/schema.prisma +4 -0
  323. package/templates/database/config/schema.prisma +91 -0
  324. package/templates/database/inbox/schema.prisma +17 -2
  325. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  326. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  327. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  328. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  329. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  330. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  331. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  332. package/templates/database/package.json +1 -1
  333. package/templates/database/seed.ts +62 -3
  334. package/versions.json +35 -35
  335. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  336. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
  337. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  338. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  339. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  340. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  341. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  342. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -26,8 +26,11 @@
26
26
  import { NextRequest, NextResponse } from "next/server";
27
27
  import createMiddleware from "next-intl/middleware";
28
28
 
29
+ import { isBookingPath } from "./lib/auth-routes";
30
+ import { SCHEDULING_PAGES } from "./lib/modules.generated";
29
31
  import {
30
32
  DEFAULT_SECURITY_HEADERS,
33
+ SCHEDULING_SECURITY_HEADERS,
31
34
  applyHeaders,
32
35
  } from "./security-headers.config";
33
36
 
@@ -91,8 +94,15 @@ export function middleware(request: NextRequest): NextResponse {
91
94
  // Step 2: run next-intl middleware (locale detection + rewrite/redirect)
92
95
  const intlResponse = intlMiddleware(requestWithPathname) as NextResponse;
93
96
 
94
- // Step 3: apply the 7 baseline security headers to the response
95
- applyHeaders(intlResponse.headers, DEFAULT_SECURITY_HEADERS);
97
+ // Step 3: apply the 7 baseline security headers to the response. The booking
98
+ // pages of a site that carries the scheduling module get the same map with
99
+ // Cal.com's origin added to the CSP (#8843 D12); no other page does.
100
+ applyHeaders(
101
+ intlResponse.headers,
102
+ SCHEDULING_PAGES && isBookingPath(request.nextUrl.pathname, enabledLocales)
103
+ ? SCHEDULING_SECURITY_HEADERS
104
+ : DEFAULT_SECURITY_HEADERS,
105
+ );
96
106
 
97
107
  return intlResponse;
98
108
  }
@@ -30,6 +30,7 @@
30
30
  "@working-theory/schema-pack-registry": "workspace:*",
31
31
  "@working-theory/schema-validator": "workspace:*",
32
32
  "@working-theory/site-config": "workspace:*",
33
+ "@working-theory/spoke-checkout": "workspace:*",
33
34
  "@working-theory/templates": "workspace:*",
34
35
  "@working-theory/theme": "workspace:*",
35
36
  "@working-theory/ui": "workspace:*",
@@ -125,6 +125,23 @@ export function buildHeaders(
125
125
  export const DEFAULT_SECURITY_HEADERS: Readonly<Record<string, string>> =
126
126
  Object.freeze(buildHeaders());
127
127
 
128
+ /**
129
+ * The booking pages' CSP additions (#8843 D12). The scheduling module's
130
+ * `scheduler-embed` section loads Cal.com's script and frames its booker, so
131
+ * these pages — and only these — name the provider origin. `'self'` in
132
+ * `frame-src` keeps same-origin frames working, which `default-src 'self'`
133
+ * allowed until the directive existed.
134
+ */
135
+ export const SCHEDULING_CSP: CspDirectiveMap = Object.freeze({
136
+ "script-src": Object.freeze(["https://app.cal.com"]),
137
+ "connect-src": Object.freeze(["https://app.cal.com"]),
138
+ "frame-src": Object.freeze(["'self'", "https://app.cal.com"]),
139
+ });
140
+
141
+ /** The header map for the booking pages: the default plus `SCHEDULING_CSP`. */
142
+ export const SCHEDULING_SECURITY_HEADERS: Readonly<Record<string, string>> =
143
+ Object.freeze(buildHeaders({ csp: SCHEDULING_CSP }));
144
+
128
145
  /** Write every entry from a header map onto a Headers object. */
129
146
  export function applyHeaders(
130
147
  target: Headers,
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The site's one lead writer (#8747): every lead carries `payload.site` (from
3
+ * `AILK_LEAD_SITE`) and `payload.page`, spread last so no caller field can
4
+ * override them; no `siteId` is sent (the hub names the tenant itself); only
5
+ * the visitor and consent-visitor headers are forwarded; a thrown submit
6
+ * resolves as `{ ok: false, status: 0 }` and logs no field value.
7
+ */
8
+ jest.mock("@working-theory/api-client", () => ({
9
+ leads: { submit: jest.fn() },
10
+ }));
11
+
12
+ import { leads } from "@working-theory/api-client";
13
+
14
+ import { writeLead } from "../lead-write";
15
+
16
+ const mockSubmit = leads.submit as jest.Mock;
17
+ const priorSite = process.env.AILK_LEAD_SITE;
18
+
19
+ afterEach(() => {
20
+ jest.clearAllMocks();
21
+ if (priorSite === undefined) delete process.env.AILK_LEAD_SITE;
22
+ else process.env.AILK_LEAD_SITE = priorSite;
23
+ });
24
+
25
+ type Body = { siteId?: unknown; source: string; email: string; payload: Record<string, unknown> };
26
+
27
+ function sentBody(): Body {
28
+ return mockSubmit.mock.calls[0][0] as Body;
29
+ }
30
+
31
+ describe("writeLead — the site marker", () => {
32
+ it("marks payload.site from AILK_LEAD_SITE and payload.page from the caller, and sends no siteId", async () => {
33
+ process.env.AILK_LEAD_SITE = "wendihenry";
34
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
35
+
36
+ const res = await writeLead({
37
+ source: "contact-form",
38
+ email: "jane@example.com",
39
+ page: "contact",
40
+ payload: { name: "Jane" },
41
+ });
42
+
43
+ expect(res.ok).toBe(true);
44
+ expect(sentBody()).toEqual({
45
+ source: "contact-form",
46
+ email: "jane@example.com",
47
+ payload: { name: "Jane", site: "wendihenry", page: "contact" },
48
+ });
49
+ expect(sentBody()).not.toHaveProperty("siteId");
50
+ });
51
+
52
+ it("a caller's own site or page field never overrides the marker", async () => {
53
+ process.env.AILK_LEAD_SITE = "wendihenry";
54
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
55
+
56
+ await writeLead({
57
+ source: "contact-form",
58
+ email: "jane@example.com",
59
+ page: "contact",
60
+ payload: { site: "attacker", page: "checkout" },
61
+ });
62
+
63
+ expect(sentBody().payload).toEqual({ site: "wendihenry", page: "contact" });
64
+ });
65
+
66
+ it("trims AILK_LEAD_SITE", async () => {
67
+ process.env.AILK_LEAD_SITE = " wendihenry\n";
68
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
69
+
70
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
71
+
72
+ expect(sentBody().payload.site).toBe("wendihenry");
73
+ });
74
+
75
+ it.each([
76
+ ["unset", undefined],
77
+ ["empty", ""],
78
+ ["blank", " "],
79
+ ])("with AILK_LEAD_SITE %s, writes no site — and drops a caller's own site", async (_label, value) => {
80
+ if (value === undefined) delete process.env.AILK_LEAD_SITE;
81
+ else process.env.AILK_LEAD_SITE = value;
82
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
83
+
84
+ await writeLead({
85
+ source: "contact-form",
86
+ email: "jane@example.com",
87
+ page: "contact",
88
+ payload: { name: "Jane", site: "attacker" },
89
+ });
90
+
91
+ expect(sentBody().payload).toEqual({ name: "Jane", page: "contact" });
92
+ });
93
+ });
94
+
95
+ describe("writeLead — forwarded headers", () => {
96
+ it("forwards only the visitor and consent-visitor headers", async () => {
97
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
98
+
99
+ await writeLead(
100
+ { source: "contact-form", email: "jane@example.com", page: "contact" },
101
+ {
102
+ headers: {
103
+ "x-ailk-visitor-ip": "203.0.113.5",
104
+ "x-ailk-visitor-forward-key": "k",
105
+ "x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
106
+ "x-ailk-spoke-key": "forged",
107
+ "content-type": "text/plain",
108
+ },
109
+ },
110
+ );
111
+
112
+ expect(mockSubmit.mock.calls[0][1]).toEqual({
113
+ headers: {
114
+ "x-ailk-visitor-ip": "203.0.113.5",
115
+ "x-ailk-visitor-forward-key": "k",
116
+ "x-ailk-consent-visitor": "6f1e2a3b-4c5d-4e6f-8a9b-0c1d2e3f4a5b",
117
+ },
118
+ });
119
+ });
120
+
121
+ it("calls leads.submit with one argument when no header survives", async () => {
122
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
123
+
124
+ await writeLead(
125
+ { source: "contact-form", email: "jane@example.com", page: "contact" },
126
+ { headers: { "x-ailk-spoke-key": "forged" } },
127
+ );
128
+
129
+ expect(mockSubmit.mock.calls[0]).toHaveLength(1);
130
+ });
131
+ });
132
+
133
+ describe("writeLead — a thrown submit", () => {
134
+ it("resolves as status 0 and logs the error name only", async () => {
135
+ mockSubmit.mockRejectedValueOnce(new TypeError("fetch failed for jane@example.com"));
136
+ const spy = jest.spyOn(console, "error").mockImplementation(() => undefined);
137
+
138
+ const res = await writeLead({
139
+ source: "contact-form",
140
+ email: "jane@example.com",
141
+ page: "contact",
142
+ payload: { name: "Jane" },
143
+ });
144
+
145
+ expect(res).toEqual({ ok: false, status: 0, body: null });
146
+ const logged = spy.mock.calls.flat().map(String).join(" ");
147
+ expect(logged).toContain("TypeError");
148
+ expect(logged).not.toContain("jane@example.com");
149
+ expect(logged).not.toContain("Jane");
150
+ spy.mockRestore();
151
+ });
152
+
153
+ it("passes the api-client's own failure through unchanged", async () => {
154
+ const failure = { ok: false, status: 503, body: { error: "lead_capture_not_configured" } };
155
+ mockSubmit.mockResolvedValueOnce(failure);
156
+
157
+ expect(
158
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" }),
159
+ ).toEqual(failure);
160
+ });
161
+ });
162
+
163
+ describe("writeLead — terms acceptance (#8730)", () => {
164
+ it("sends consent: true at the top level when the caller says the terms were accepted", async () => {
165
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
166
+
167
+ await writeLead({
168
+ source: "contact-form",
169
+ email: "jane@example.com",
170
+ page: "contact",
171
+ consent: true,
172
+ payload: { name: "Jane" },
173
+ });
174
+
175
+ const body = mockSubmit.mock.calls[0][0] as Record<string, unknown>;
176
+ expect(body["consent"]).toBe(true);
177
+ expect((body["payload"] as Record<string, unknown>)["consent"]).toBeUndefined();
178
+ });
179
+
180
+ it("sends no consent key at all when the caller does not set it", async () => {
181
+ mockSubmit.mockResolvedValueOnce({ ok: true, body: {} });
182
+
183
+ await writeLead({ source: "contact-form", email: "jane@example.com", page: "contact" });
184
+
185
+ expect(mockSubmit.mock.calls[0][0]).not.toHaveProperty("consent");
186
+ });
187
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The per-visitor limiter the /contact action runs before it writes a lead
3
+ * (#8747): a fixed window per key, refusing the call past the limit, and a
4
+ * client address read from the platform's own headers.
5
+ */
6
+ import {
7
+ __resetRateLimit,
8
+ checkRateLimit,
9
+ clientIpFromHeaders,
10
+ } from "../rate-limit";
11
+
12
+ const WINDOW_MS = 10 * 60 * 1000;
13
+
14
+ function headersOf(values: Record<string, string>): { get(name: string): string | null } {
15
+ return { get: (name) => values[name] ?? null };
16
+ }
17
+
18
+ afterEach(() => {
19
+ __resetRateLimit();
20
+ jest.useRealTimers();
21
+ });
22
+
23
+ describe("checkRateLimit", () => {
24
+ it("admits exactly `limit` calls in a window and refuses the next", () => {
25
+ for (let i = 0; i < 30; i += 1) {
26
+ expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(true);
27
+ }
28
+ expect(checkRateLimit("contact:203.0.113.5", 30, WINDOW_MS)).toBe(false);
29
+ });
30
+
31
+ it("counts each key on its own", () => {
32
+ for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
33
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
34
+ expect(checkRateLimit("contact:b", 30, WINDOW_MS)).toBe(true);
35
+ });
36
+
37
+ it("opens a new window once the old one ends", () => {
38
+ jest.useFakeTimers();
39
+ jest.setSystemTime(new Date("2026-10-15T10:00:00Z"));
40
+ for (let i = 0; i < 30; i += 1) checkRateLimit("contact:a", 30, WINDOW_MS);
41
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(false);
42
+
43
+ jest.setSystemTime(new Date("2026-10-15T10:10:00Z"));
44
+ expect(checkRateLimit("contact:a", 30, WINDOW_MS)).toBe(true);
45
+ });
46
+ });
47
+
48
+ describe("clientIpFromHeaders", () => {
49
+ it("prefers x-vercel-forwarded-for, first value", () => {
50
+ expect(
51
+ clientIpFromHeaders(
52
+ headersOf({
53
+ "x-vercel-forwarded-for": "198.51.100.7, 10.0.0.1",
54
+ "x-forwarded-for": "203.0.113.5",
55
+ }),
56
+ ),
57
+ ).toBe("198.51.100.7");
58
+ });
59
+
60
+ it("falls back to x-forwarded-for, then x-real-ip, then unknown", () => {
61
+ expect(clientIpFromHeaders(headersOf({ "x-forwarded-for": "203.0.113.5, 10.0.0.1" }))).toBe(
62
+ "203.0.113.5",
63
+ );
64
+ expect(clientIpFromHeaders(headersOf({ "x-real-ip": "192.0.2.9" }))).toBe("192.0.2.9");
65
+ expect(clientIpFromHeaders(headersOf({}))).toBe("unknown");
66
+ });
67
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * lead-write.ts — this site's lead writer (#8747): the /contact action writes
3
+ * through it, and so can any other page that writes a lead.
4
+ *
5
+ * A lead goes to `POST /v1/leads/submit` through `@working-theory/api-client`.
6
+ * The API resolves the lead's workspace itself — from the spoke key on a hub,
7
+ * from its own site alias otherwise — and ignores a body `siteId`, so none is
8
+ * sent. The site marks itself in the payload instead: `payload.site` is
9
+ * `AILK_LEAD_SITE` (the same variable `apps/mcp`'s `capture_lead` reads,
10
+ * `LEAD_SITE_MARKER_KEY`) and `payload.page` the page key, both spread LAST so
11
+ * no caller field can override them. With `AILK_LEAD_SITE` unset or blank the
12
+ * lead carries no `site` at all — a caller's own `site` is dropped, never sent.
13
+ *
14
+ * The marker is attribution, not authorization: the route is public and a
15
+ * direct caller can write any `payload.site`. Nothing may gate on it.
16
+ *
17
+ * Terms acceptance (#8730): a caller that collected the visitor's acceptance
18
+ * passes `consent: true`, sent as the body's top-level `consent`. The API
19
+ * refuses a contact lead without it and records `payload.consent` itself, so
20
+ * nothing about the acceptance is written into the payload here.
21
+ *
22
+ * Forwarded headers: only the two `visitorForwardHeaders` builds (#7394) and
23
+ * the consent-visitor id (#8438 D12) reach the api-client, so no caller can
24
+ * set its own `x-ailk-spoke-key` or `content-type`. With none of the three,
25
+ * `leads.submit` is called with one argument.
26
+ *
27
+ * Never throws. The api-client's own result passes through unchanged, so a
28
+ * caller still reads the API's 503 `lead_capture_not_configured`. A thrown
29
+ * submit — the API could not be reached — resolves as
30
+ * `{ ok: false, status: 0, body: null }` and logs the error's NAME only: its
31
+ * message could echo the payload.
32
+ */
33
+ import { leads } from "@working-theory/api-client";
34
+ import { LEAD_SITE_MARKER_KEY } from "@working-theory/validation";
35
+
36
+ export interface LeadWriteInput {
37
+ source: string;
38
+ email: string;
39
+ /** The page key the marker carries, e.g. `contact` or `checkout`. */
40
+ page: string;
41
+ /** The visitor accepted the terms (#8730). Sent only when `true`. */
42
+ consent?: boolean;
43
+ payload?: Record<string, unknown>;
44
+ }
45
+
46
+ type SubmitInit = Parameters<typeof leads.submit>[1];
47
+ type SubmitResult = Awaited<ReturnType<typeof leads.submit>>;
48
+
49
+ /** The only request headers a lead write forwards. */
50
+ const FORWARDED_HEADERS = [
51
+ "x-ailk-visitor-ip",
52
+ "x-ailk-visitor-forward-key",
53
+ "x-ailk-consent-visitor",
54
+ ] as const;
55
+
56
+ function forwardOnly(init: SubmitInit): SubmitInit {
57
+ const headers: Record<string, string> = {};
58
+ for (const name of FORWARDED_HEADERS) {
59
+ const value = init?.headers?.[name];
60
+ if (typeof value === "string") headers[name] = value;
61
+ }
62
+ return Object.keys(headers).length > 0 ? { headers } : undefined;
63
+ }
64
+
65
+ /** The site marker from configuration, or `undefined` when unset or blank. */
66
+ function leadSite(): string | undefined {
67
+ return process.env.AILK_LEAD_SITE?.trim() || undefined;
68
+ }
69
+
70
+ export async function writeLead(input: LeadWriteInput, init?: SubmitInit): Promise<SubmitResult> {
71
+ const site = leadSite();
72
+ const { [LEAD_SITE_MARKER_KEY]: _callerSite, ...callerPayload } = input.payload ?? {};
73
+ const body = {
74
+ source: input.source,
75
+ email: input.email,
76
+ ...(input.consent === true && { consent: true }),
77
+ payload: {
78
+ ...callerPayload,
79
+ ...(site && { [LEAD_SITE_MARKER_KEY]: site }),
80
+ page: input.page,
81
+ },
82
+ };
83
+ const forward = forwardOnly(init);
84
+ try {
85
+ return forward ? await leads.submit(body, forward) : await leads.submit(body);
86
+ } catch (err) {
87
+ console.error(`[lead-write] submit failed: ${err instanceof Error ? err.name : "unknown"}`);
88
+ return { ok: false, status: 0, body: null };
89
+ }
90
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * rate-limit.ts — a best-effort, in-memory, per-instance limiter (#8747).
3
+ *
4
+ * The /contact Server Action runs it before it writes a lead. From a server
5
+ * action the hub sees this site's own egress address, so without a limit
6
+ * here one visitor could drain the hub's per-IP budget for every visitor of
7
+ * the site. Brought upstream from the consumer sites' copies
8
+ * (salessmyth/samhenry, salessmyth/wendihenry `apps/web/src/lib/rate-limit.ts`).
9
+ *
10
+ * NOT a distributed limiter: state lives in one serverless instance's memory
11
+ * and resets on a cold start. It raises the bar against a flood on one warm
12
+ * instance; it does not stop one spread across many. The hub keeps its own
13
+ * per-IP limit on the route.
14
+ */
15
+
16
+ type Bucket = { count: number; resetAt: number };
17
+
18
+ const buckets = new Map<string, Bucket>();
19
+
20
+ // Bound the map so a caller rotating keys (spoofed addresses) cannot grow it
21
+ // without limit between resets.
22
+ const MAX_TRACKED_KEYS = 5000;
23
+
24
+ /** Test-only reset. Not reachable from a request. */
25
+ export function __resetRateLimit(): void {
26
+ buckets.clear();
27
+ }
28
+
29
+ /**
30
+ * Fixed-window check. `true` when the caller is under `limit` in the current
31
+ * window (and counts the call); `false` once it has used `limit` calls.
32
+ */
33
+ export function checkRateLimit(key: string, limit: number, windowMs: number): boolean {
34
+ const now = Date.now();
35
+ const existing = buckets.get(key);
36
+
37
+ if (!existing || existing.resetAt <= now) {
38
+ if (buckets.size >= MAX_TRACKED_KEYS) pruneExpired(now);
39
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
40
+ return true;
41
+ }
42
+
43
+ if (existing.count >= limit) return false;
44
+
45
+ existing.count += 1;
46
+ return true;
47
+ }
48
+
49
+ function pruneExpired(now: number): void {
50
+ for (const [key, bucket] of buckets) {
51
+ if (bucket.resetAt <= now) buckets.delete(key);
52
+ }
53
+ // Nothing expired (keys rotated inside one window): evict the oldest entry
54
+ // — `Map` iterates in insertion order — so the bound always holds.
55
+ if (buckets.size >= MAX_TRACKED_KEYS) {
56
+ const oldestKey = buckets.keys().next().value;
57
+ if (oldestKey !== undefined) buckets.delete(oldestKey);
58
+ }
59
+ }
60
+
61
+ /** The subset of Next's `headers()` the address read needs. */
62
+ export interface HeaderReader {
63
+ get(name: string): string | null;
64
+ }
65
+
66
+ /**
67
+ * The visitor's address, coarse: `x-vercel-forwarded-for`, else
68
+ * `x-forwarded-for`, else `x-real-ip` (first value of each), else
69
+ * `"unknown"`. Vercel's edge overwrites these with the connecting address;
70
+ * behind an edge that appends instead, a caller chooses its own key. An
71
+ * abuse-throttling key only — never an authorization decision.
72
+ */
73
+ export function clientIpFromHeaders(headers: HeaderReader): string {
74
+ for (const name of ["x-vercel-forwarded-for", "x-forwarded-for"]) {
75
+ const value = headers.get(name);
76
+ if (value) return value.split(",")[0]?.trim() || "unknown";
77
+ }
78
+ return headers.get("x-real-ip") || "unknown";
79
+ }
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  type: "BlogList"
3
3
  slug: "blog"
4
- title: "Get free blog/docs from Fumadocs"
5
- description: "Or upgrade to Pro and get Fumadocs with our easy-to-use editor."
4
+ title: "{Blog}"
5
+ description: "{One sentence on what you write about}"
6
6
  sections:
7
7
  [
8
8
  {
@@ -13,75 +13,8 @@ sections:
13
13
  "variant": "centered",
14
14
  "gapBelow": "tight",
15
15
  "eyebrow": "Blog",
16
- "headline": "Get free blog/docs from Fumadocs",
17
- "subheadline": "Or upgrade to Pro and get Fumadocs with our easy-to-use editor.",
18
- "primaryCta":
19
- { "label": "Get Fumadocs", "href": "https://www.fumadocs.dev" },
20
- "secondaryCta":
21
- {
22
- "label": "Upgrade to Pro",
23
- "href": "https://ailaunchkit.ai/pricing",
24
- "variant": "outline",
25
- },
26
- },
27
- },
28
- {
29
- "id": "posts",
30
- "type": "blog-list",
31
- "data":
32
- {
33
- "variant": "grid",
34
- "posts":
35
- [
36
- {
37
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
38
- "slug": "post-1",
39
- "href": "https://www.fumadocs.dev",
40
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
41
- "publishedAt": "2026-01-01",
42
- "readingTime": "5 min",
43
- },
44
- {
45
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
46
- "slug": "post-2",
47
- "href": "https://ailaunchkit.ai/pricing",
48
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
49
- "publishedAt": "2026-01-02",
50
- "readingTime": "5 min",
51
- },
52
- {
53
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
54
- "slug": "post-3",
55
- "href": "https://www.fumadocs.dev",
56
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
57
- "publishedAt": "2026-01-03",
58
- "readingTime": "5 min",
59
- },
60
- {
61
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
62
- "slug": "post-4",
63
- "href": "https://ailaunchkit.ai/pricing",
64
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
65
- "publishedAt": "2026-01-04",
66
- "readingTime": "5 min",
67
- },
68
- {
69
- "title": "Free Fumadocs Blog: publish your blog and docs with Fumadocs",
70
- "slug": "post-5",
71
- "href": "https://www.fumadocs.dev",
72
- "excerpt": "The Free template ships with Fumadocs, the open-source docs and blog engine. Write in Markdown, keep this design system, and publish from your repo.",
73
- "publishedAt": "2026-01-05",
74
- "readingTime": "5 min",
75
- },
76
- {
77
- "title": "Upgrade to Pro: Fumadocs with our easy-to-use editor",
78
- "slug": "post-6",
79
- "href": "https://ailaunchkit.ai/pricing",
80
- "excerpt": "Upgrade to Pro and write your posts and docs in a visual editor — no Markdown, no repo, the same Fumadocs pages. See what Pro includes.",
81
- "publishedAt": "2026-01-06",
82
- "readingTime": "5 min",
83
- },
84
- ],
16
+ "headline": "{Blog}",
17
+ "subheadline": "{One sentence on what you write about}",
85
18
  },
86
19
  },
87
20
  {
@@ -20,46 +20,6 @@ sections:
20
20
  "primaryCta": { "label": "{Get in touch}", "href": "/contact" },
21
21
  },
22
22
  },
23
- {
24
- "id": "add-scheduling",
25
- "type": "cta",
26
- "data":
27
- {
28
- "variant": "box",
29
- "headline": "{What booking a call would do for your visitors}",
30
- "description": "{One or two sentences on what live scheduling adds once it's on}",
31
- "primaryCta":
32
- {
33
- "label": "Add scheduling with Pro",
34
- "href": "https://ailaunchkit.ai/pricing",
35
- "external": true,
36
- },
37
- },
38
- },
39
- {
40
- "id": "what-happens-on-the-call",
41
- "type": "items",
42
- "data":
43
- {
44
- "headline": "What happens on the call?",
45
- "columns": 3,
46
- "items":
47
- [
48
- {
49
- "title": "{The first thing you cover}",
50
- "description": "{One or two sentences on what the visitor learns or decides here}",
51
- },
52
- {
53
- "title": "{The second thing you cover}",
54
- "description": "{One or two sentences on what the visitor learns or decides here}",
55
- },
56
- {
57
- "title": "{The next step you agree}",
58
- "description": "{One or two sentences on what happens after the call}",
59
- },
60
- ],
61
- },
62
- },
63
23
  {
64
24
  "id": "back-to-meet",
65
25
  "type": "content-block",
@@ -77,4 +37,4 @@ sections:
77
37
  ]
78
38
  ---
79
39
 
80
- {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A template that carries the scheduling module overrides this file with the live booker. The "What happens on the call?" section moved here verbatim from the retired /book-a-call page (#6897 D10). Placeholder copy — replace every {brace} with your own words before you launch. */}
40
+ {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A site that carries the scheduling module renders the hub's meeting page instead, reading this seed only for its URL, its JSON-LD and its fallback title (#8843 C5). Placeholder copy — replace every {brace} with your own words before you launch. */}
@@ -20,22 +20,6 @@ sections:
20
20
  "primaryCta": { "label": "{Get in touch}", "href": "/contact" },
21
21
  },
22
22
  },
23
- {
24
- "id": "add-scheduling",
25
- "type": "cta",
26
- "data":
27
- {
28
- "variant": "box",
29
- "headline": "{What booking an onsite estimate would do for your visitors}",
30
- "description": "{One or two sentences on what live scheduling adds once it's on}",
31
- "primaryCta":
32
- {
33
- "label": "Add scheduling with Pro",
34
- "href": "https://ailaunchkit.ai/pricing",
35
- "external": true,
36
- },
37
- },
38
- },
39
23
  {
40
24
  "id": "back-to-meet",
41
25
  "type": "content-block",
@@ -53,4 +37,4 @@ sections:
53
37
  ]
54
38
  ---
55
39
 
56
- {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A template that carries the scheduling module overrides this file with the live booker. Placeholder copy — replace every {brace} with your own words before you launch. */}
40
+ {/* Rendered from frontmatter `sections[]` by the /meet/[slug] placeholder route. A site that carries the scheduling module renders the hub's meeting page instead, reading this seed only for its URL, its JSON-LD and its fallback title (#8843 C5). Placeholder copy — replace every {brace} with your own words before you launch. */}