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
@@ -65,7 +65,8 @@
65
65
  * (#7644) — `GET /v1/schedule/availability` and `POST /v1/schedule/book` — and
66
66
  * since #7479 the three listing-draft calls a spoke makes for its applicant
67
67
  * (`POST /v1/project-listings`, `PATCH /v1/project-listings/drafts/:id`,
68
- * `POST /v1/project-listings/drafts/:id/submit`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
68
+ * `POST /v1/project-listings/drafts/:id/submit`), and since #8848 the open
69
+ * file read (`GET /v1/deliverables/open/:slug`). The global limiter (`server.ts`) passes the hub's digests, so a listed call is
69
70
  * counted per visitor there; a scope's own limiter does so only when its
70
71
  * registration passes `trustedKeyDigests` too, as the leads and schedule
71
72
  * scopes do. A believed visitor is
@@ -76,6 +77,21 @@
76
77
  * request keys on `req.ip`, as before. Spec:
77
78
  * docs/specs/7394-the-leads-rate-limit-scope-spec.md.
78
79
  *
80
+ * Per-site-key sign-up evidence (#8805): the web's server reads
81
+ * `GET /v1/consent/sign-up` on every sign-up, and every web instance behind
82
+ * one egress address shared one per-IP bucket, which an anonymous caller
83
+ * spoofing that address's `X-Forwarded-For` could fill. A registration that
84
+ * passes `siteKeyOf` (built by `siteKeyReader`) counts a request on one of
85
+ * the `SITE_KEYED_ROUTES` that presents a VERIFIED site key
86
+ * (`x-ailk-spoke-key`, the key the web's api-client already sends) against
87
+ * that key's own `site,<spokeId>` bucket and the scope's per-key ceiling
88
+ * only — never an IP bucket. No other identity starts with `site,` (`req.ip`
89
+ * holds no comma, a forwarded visitor is `v<n>,<ip>`), so no unkeyed caller
90
+ * can reach it. An invalid or absent key, or a verification that fails or
91
+ * outlasts `SITE_KEY_TIMEOUT_MS`, is counted on `req.ip` as before. The key changes
92
+ * the request's rate-limit identity and nothing else: the route still serves
93
+ * the deployment's own site workspace.
94
+ *
79
95
  * Scope namespace and env limit (#7394): a registration with a `scope` keys
80
96
  * its buckets as `<scope>|<identity>`, so its count is not shared with any
81
97
  * other registration, and reads its limits from
@@ -146,6 +162,13 @@ export type RateLimitOptions = {
146
162
  * visitors (#7394). Default: 300.
147
163
  */
148
164
  perKeyPerMinute?: number;
165
+ /**
166
+ * The verified site key a request presents, as its spoke id, or `null`
167
+ * (#8805). Build it with `siteKeyReader`. A request it names is counted
168
+ * against `<scope>|site,<spokeId>` and `perKeyPerMinute` only, never an IP
169
+ * bucket. Needs a `scope`.
170
+ */
171
+ siteKeyOf?: (req: FastifyRequest) => Promise<string | null>;
149
172
  /**
150
173
  * Override the process-default store (#5040 D4). Test-only, like
151
174
  * `maxBuckets` — no production caller overrides this.
@@ -181,6 +204,14 @@ export const MAX_BUCKETS = 10_000;
181
204
  */
182
205
  export const DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE = 60;
183
206
 
207
+ /**
208
+ * The sign-up evidence read's per-site-key ceiling (#8805). The web's cache
209
+ * keeps each instance to about one read a minute per enabled locale, so this
210
+ * covers many instances of one site at once.
211
+ * `AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE` overrides it.
212
+ */
213
+ export const DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE = 120;
214
+
184
215
  /**
185
216
  * How long a database-store call may run before the middleware falls back to
186
217
  * the memory store for that request (#5040 D5). `RateLimitOptions.storeTimeoutMs`
@@ -218,8 +249,75 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
218
249
  { method: "POST", url: "/v1/project-listings" },
219
250
  { method: "PATCH", url: "/v1/project-listings/drafts/:id" },
220
251
  { method: "POST", url: "/v1/project-listings/drafts/:id/submit" },
252
+ // #8848 D5 — an ungated file's bytes, which a site's own route handler
253
+ // reads for its visitor (`/reports/<slug>/file`), in `deliverableopen`.
254
+ { method: "GET", url: "/v1/deliverables/open/:slug" },
221
255
  ];
222
256
 
257
+ /**
258
+ * The only (method, route) pairs on which a verified site key is the
259
+ * caller's rate-limit identity (#8805): the sign-up agreements' evidence read.
260
+ */
261
+ export const SITE_KEYED_ROUTES: readonly Readonly<{
262
+ method: "GET";
263
+ url: string;
264
+ }>[] = [{ method: "GET", url: "/v1/consent/sign-up" }];
265
+
266
+ /**
267
+ * A verified site key's identity prefix. No other identity starts with it:
268
+ * `req.ip` never holds a comma, and a forwarded visitor is `v<n>,<ip>`.
269
+ */
270
+ export const SITE_KEY_IDENTITY_PREFIX = "site,";
271
+
272
+ /**
273
+ * How long a site-key lookup may run before the request is counted per IP
274
+ * instead (#8805) — the store's own bound (#5040 D5).
275
+ */
276
+ export const SITE_KEY_TIMEOUT_MS = 1_000;
277
+
278
+ const SITE_KEY_HEADER = "x-ailk-spoke-key";
279
+
280
+ /**
281
+ * The `siteKeyOf` reader (#8805). `verify` turns a request's site key into
282
+ * its spoke id, or `null` for a key that is not a registered, unrevoked one;
283
+ * it is called only for a request on one of the `SITE_KEYED_ROUTES` that
284
+ * carries a non-empty `x-ailk-spoke-key`, and at most once per request, so
285
+ * the global limiter and the scope's own share one lookup. A `verify` that
286
+ * throws, or has not answered within `timeoutMs`, reads as `null` — the
287
+ * request is counted per IP, never unlimited — and logs the error's name only.
288
+ */
289
+ export function siteKeyReader(
290
+ verify: (req: FastifyRequest) => Promise<string | null>,
291
+ timeoutMs: number = SITE_KEY_TIMEOUT_MS,
292
+ ): (req: FastifyRequest) => Promise<string | null> {
293
+ const verified = new WeakMap<FastifyRequest, Promise<string | null>>();
294
+ return (req) => {
295
+ const url = req.routeOptions.url ?? "";
296
+ const listed = SITE_KEYED_ROUTES.some(
297
+ (route) => route.method === req.method && route.url === url,
298
+ );
299
+ const presented = req.headers[SITE_KEY_HEADER];
300
+ if (!listed || typeof presented !== "string" || presented.trim() === "") {
301
+ return Promise.resolve(null);
302
+ }
303
+ let pending = verified.get(req);
304
+ if (pending === undefined) {
305
+ pending = withTimeout(verify(req), timeoutMs).catch((err: unknown) => {
306
+ req.log.warn(
307
+ {
308
+ event: "rate_limit_site_key_unverified",
309
+ err: err instanceof Error ? err.name : "unknown",
310
+ },
311
+ "rate_limit_site_key_unverified",
312
+ );
313
+ return null;
314
+ });
315
+ verified.set(req, pending);
316
+ }
317
+ return pending;
318
+ };
319
+ }
320
+
223
321
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";
224
322
  const FORWARD_KEY_HEADER = "x-ailk-visitor-forward-key";
225
323
  const MIN_FORWARD_KEY_LENGTH = 32;
@@ -593,6 +691,10 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
593
691
  done(new Error("rate-limit trustedKeyDigests needs a scope"));
594
692
  return;
595
693
  }
694
+ if (opts.siteKeyOf !== undefined && scope === undefined) {
695
+ done(new Error("rate-limit siteKeyOf needs a scope"));
696
+ return;
697
+ }
596
698
 
597
699
  const warn = (msg: string) => fastify.log.warn(msg);
598
700
  const perIpPerMinute = resolveScopedLimit(
@@ -608,7 +710,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
608
710
  opts.perKeyPerMinute ?? DEFAULT_PER_KEY_PER_MINUTE,
609
711
  warn,
610
712
  );
611
- const { ipFromRequest } = opts;
713
+ const { ipFromRequest, siteKeyOf } = opts;
612
714
  const identityOf: (req: FastifyRequest) => {
613
715
  identity: string;
614
716
  keyIndex: number | null;
@@ -631,6 +733,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
631
733
  activeStore: RateLimitStore,
632
734
  ip: string,
633
735
  keyIndex: number | null,
736
+ siteId: string | null,
634
737
  now: number,
635
738
  sweepOnInterval: boolean,
636
739
  ): Promise<{
@@ -642,6 +745,23 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
642
745
  await activeStore.sweep(now, windowMs, "interval");
643
746
  }
644
747
 
748
+ // #8805: a verified site key is counted against its own bucket and the
749
+ // per-key ceiling only — no IP bucket is read or opened.
750
+ if (siteId !== null) {
751
+ const siteBucket = await activeStore.hitKey(
752
+ bucketKey(`${SITE_KEY_IDENTITY_PREFIX}${siteId}`),
753
+ now,
754
+ windowMs,
755
+ );
756
+ const siteOver = siteBucket.count > perKeyPerMinute;
757
+ if (siteOver) await activeStore.sweep(now, windowMs, "limited");
758
+ return {
759
+ tripped: siteOver ? siteBucket : undefined,
760
+ visitorOver: false,
761
+ keyOver: siteOver,
762
+ };
763
+ }
764
+
645
765
  // #7394 DD-8: a keyed request also counts against its key's own
646
766
  // bucket, even when the visitor bucket is already over its limit.
647
767
  const keyBucket =
@@ -675,7 +795,11 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
675
795
  "onRequest",
676
796
  async (req: FastifyRequest, reply: FastifyReply) => {
677
797
  const now = getClock().now();
678
- const { identity: ip, keyIndex } = identityOf(req);
798
+ const siteId = siteKeyOf ? await siteKeyOf(req) : null;
799
+ const { identity: ip, keyIndex } =
800
+ siteId === null
801
+ ? identityOf(req)
802
+ : { identity: `${SITE_KEY_IDENTITY_PREFIX}${siteId}`, keyIndex: null };
679
803
 
680
804
  requestsSinceLastClean += 1;
681
805
  const sweepOnInterval = requestsSinceLastClean >= CLEAN_INTERVAL_REQUESTS;
@@ -697,7 +821,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
697
821
  };
698
822
  try {
699
823
  outcome = await withTimeout(
700
- countRequest(primaryStore, ip, keyIndex, now, sweepOnInterval),
824
+ countRequest(primaryStore, ip, keyIndex, siteId, now, sweepOnInterval),
701
825
  storeTimeoutMs,
702
826
  );
703
827
  } catch (err) {
@@ -710,6 +834,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
710
834
  fallbackStore,
711
835
  ip,
712
836
  keyIndex,
837
+ siteId,
713
838
  now,
714
839
  sweepOnInterval,
715
840
  );
@@ -730,6 +855,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
730
855
  const pathOnly = req.url.split("?")[0] ?? req.url;
731
856
  // A keyed 429 names the bucket that tripped and the key's position,
732
857
  // so a misused key can be traced to its spoke; never the key itself.
858
+ // A site-keyed 429 (#8805) names `bucket: "site"` and the spoke id.
733
859
  req.log.warn(
734
860
  {
735
861
  ip,
@@ -739,6 +865,7 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
739
865
  bucket: visitorOver ? "visitor" : "key",
740
866
  keyIndex,
741
867
  }),
868
+ ...(siteId !== null && { bucket: "site" }),
742
869
  },
743
870
  RATE_LIMITED,
744
871
  );
@@ -759,3 +886,98 @@ export const rateLimitMiddleware: FastifyPluginCallback<RateLimitOptions> = (
759
886
  (rateLimitMiddleware as FastifyPluginCallback & { [key: symbol]: boolean })[
760
887
  Symbol.for("skip-override")
761
888
  ] = true;
889
+
890
+ // ─── The Stripe connection write budget (#8907 D9, contract K10) ────────────
891
+
892
+ /** One Organization's connection writes in an hour (K10). */
893
+ export const STRIPE_CONNECTION_WRITES_PER_ORG_PER_HOUR = 10;
894
+ /** One person's connection writes in an hour, across Organizations (K10). */
895
+ export const STRIPE_CONNECTION_WRITES_PER_USER_PER_HOUR = 20;
896
+
897
+ /** One bucket a write is counted against: `org,<id>` or `user,<id>`. */
898
+ export type WriteBudgetCharge = { key: string; limit: number };
899
+
900
+ export type WriteBudgetResult = { ok: true } | { ok: false; retryAfterSeconds: number };
901
+
902
+ /**
903
+ * The budget's own database store (D9, SEC-8907-2) — never the process
904
+ * default, so `AILK_RATE_LIMIT_STORE=memory` can never send an hourly bucket
905
+ * into the shared `keyBuckets` map a per-minute sweep clears. Each database
906
+ * row keeps its own `window_ms`, so no sweep shortens it.
907
+ */
908
+ let writeBudgetStore: RateLimitStore | undefined;
909
+
910
+ /**
911
+ * Where a request is counted while the database store rejects or times out.
912
+ * Module-private and never touched by `evictStaleBuckets`; capped at
913
+ * `MAX_BUCKETS`, the oldest entry dropped first, and an entry replaced only
914
+ * once its own window has ended. Per instance while the database is
915
+ * degraded — the residual SEC-8907-15 accepts.
916
+ */
917
+ const writeBudgetFallback = new Map<string, RateLimitBucket>();
918
+
919
+ function fallbackHit(key: string, now: number, windowMs: number): RateLimitBucket {
920
+ let bucket = writeBudgetFallback.get(key);
921
+ if (!bucket || now - bucket.windowStartMs >= windowMs) {
922
+ writeBudgetFallback.delete(key);
923
+ if (writeBudgetFallback.size >= MAX_BUCKETS) {
924
+ const oldestKey = writeBudgetFallback.keys().next().value;
925
+ if (oldestKey !== undefined) writeBudgetFallback.delete(oldestKey);
926
+ }
927
+ bucket = { count: 0, windowStartMs: now };
928
+ writeBudgetFallback.set(key, bucket);
929
+ }
930
+ bucket.count += 1;
931
+ return bucket;
932
+ }
933
+
934
+ /**
935
+ * Count one write against every charge's bucket and answer whether all of
936
+ * them are still within their limit (D9). Every call counts — a refused key
937
+ * costs a write too, so the probes are never a key-validity oracle. On a
938
+ * refusal, `retryAfterSeconds` is the latest window end among the refusing
939
+ * buckets, at least 1.
940
+ */
941
+ export async function chargeWriteBudget(input: {
942
+ scope: string;
943
+ charges: readonly WriteBudgetCharge[];
944
+ windowMs: number;
945
+ now: number;
946
+ warn?: (obj: Record<string, unknown>, msg: string) => void;
947
+ }): Promise<WriteBudgetResult> {
948
+ const { scope, charges, windowMs, now } = input;
949
+ writeBudgetStore ??= createDatabaseRateLimitStore(prisma);
950
+ const activeStore = writeBudgetStore;
951
+
952
+ let retryAfterSeconds = 0;
953
+ for (const charge of charges) {
954
+ const key = `writebudget:${scope}|${charge.key}`;
955
+ let bucket: RateLimitBucket;
956
+ try {
957
+ bucket = await withTimeout(activeStore.hitKey(key, now, windowMs), STORE_TIMEOUT_MS);
958
+ } catch (err) {
959
+ warnFallback(
960
+ input.warn ?? (() => undefined),
961
+ now,
962
+ err instanceof RateLimitStoreTimeoutError ? "timeout" : "error",
963
+ );
964
+ bucket = fallbackHit(key, now, windowMs);
965
+ }
966
+ if (bucket.count > charge.limit) {
967
+ const endsInMs = bucket.windowStartMs + windowMs - now;
968
+ retryAfterSeconds = Math.max(retryAfterSeconds, Math.ceil(endsInMs / 1000), 1);
969
+ }
970
+ }
971
+ return retryAfterSeconds > 0 ? { ok: false, retryAfterSeconds } : { ok: true };
972
+ }
973
+
974
+ /** Test-only: replace the budget's store, as the middleware's `store` option does for a registration. */
975
+ export function __setWriteBudgetStoreForTests(store: RateLimitStore | undefined): void {
976
+ writeBudgetStore = store;
977
+ writeBudgetFallback.clear();
978
+ }
979
+
980
+ /** Test-only: the fallback map's size. */
981
+ export function __writeBudgetFallbackCount(): number {
982
+ return writeBudgetFallback.size;
983
+ }
@@ -758,7 +758,14 @@ export function buildOpenApiSpec(
758
758
  "lead's id with a 201; with anything different it is a 422. Keys are " +
759
759
  "scoped per workspace, are first-write-wins, and should be unguessable " +
760
760
  "(a UUID, a Stripe Checkout Session id). Without a database there is no " +
761
- "stored lead to match, so the key has no effect.",
761
+ "stored lead to match, so the key has no effect. A consent-bearing lead — " +
762
+ "`source` `contact-form`, `conversational-intake`, `flow:contact` or " +
763
+ "`flow:job-application` — must carry `consent: true` (the visitor " +
764
+ "accepted the site's terms), " +
765
+ "else it is a 400 `consent_required`; the server then records " +
766
+ "`payload.consent` (`statement`, `acceptedAt`) itself, replacing any the " +
767
+ "caller sent, and leaves it out of the Idempotency-Key replay " +
768
+ "comparison. Other sources do not read `consent`.",
762
769
  parameters: [
763
770
  {
764
771
  name: "Idempotency-Key",
@@ -789,10 +796,17 @@ export function buildOpenApiSpec(
789
796
  },
790
797
  "400": {
791
798
  description:
792
- "Validation error — invalid request body, or a malformed Idempotency-Key",
799
+ "Validation error — invalid request body, or a malformed " +
800
+ "Idempotency-Key; or `consent_required`, a consent-bearing lead " +
801
+ "(a contact lead or a job application) without `consent: true`",
793
802
  content: {
794
803
  "application/json": {
795
- schema: { $ref: "#/components/schemas/InvalidInputResponse" },
804
+ schema: {
805
+ oneOf: [
806
+ { $ref: "#/components/schemas/InvalidInputResponse" },
807
+ { $ref: "#/components/schemas/ConsentRequiredResponse" },
808
+ ],
809
+ },
796
810
  },
797
811
  },
798
812
  },
@@ -1472,6 +1486,14 @@ export function buildOpenApiSpec(
1472
1486
  "Free-form caller-set channel tag (e.g. `agent`); no CR/LF",
1473
1487
  },
1474
1488
  email: { type: "string", format: "email", maxLength: 254 },
1489
+ consent: {
1490
+ type: "boolean",
1491
+ description:
1492
+ "`true` when the visitor accepted the site's terms. Required (as " +
1493
+ "`true`) for a consent-bearing lead — a contact lead or a job " +
1494
+ "application; read for no other source, and never " +
1495
+ "stored as sent — the server records `payload.consent` instead.",
1496
+ },
1475
1497
  payload: {
1476
1498
  type: "object",
1477
1499
  maxProperties: 64,
@@ -1575,6 +1597,14 @@ export function buildOpenApiSpec(
1575
1597
  message: { type: "string" },
1576
1598
  },
1577
1599
  },
1600
+ ConsentRequiredResponse: {
1601
+ type: "object",
1602
+ required: ["error", "message"],
1603
+ properties: {
1604
+ error: { type: "string", enum: ["consent_required"] },
1605
+ message: { type: "string" },
1606
+ },
1607
+ },
1578
1608
  PersistFailedResponse: {
1579
1609
  type: "object",
1580
1610
  required: ["error"],
@@ -35,6 +35,7 @@ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
35
35
  import { __resetHubSignInBuckets } from "../../../services/hub-sign-in.js";
36
36
  import {
37
37
  hubSignInClientRoutes,
38
+ hubSignInPublicRoutes,
38
39
  hubSignInSessionRoutes,
39
40
  } from "../hub-sign-in.js";
40
41
 
@@ -56,6 +57,7 @@ function buildApp(): FastifyInstance {
56
57
  await authScope.register(hubSignInSessionRoutes);
57
58
  });
58
59
  server.register(hubSignInClientRoutes);
60
+ server.register(hubSignInPublicRoutes);
59
61
  return server;
60
62
  }
61
63
 
@@ -481,3 +483,50 @@ describe("GET /v1/hub-sign-in/me", () => {
481
483
  expect(wrongKey.statusCode).toBe(401);
482
484
  });
483
485
  });
486
+
487
+ describe("GET /v1/hub-sign-in/clients/:clientId (#8790 D2)", () => {
488
+ const read = (clientId: string) =>
489
+ app.inject({
490
+ method: "GET",
491
+ url: `/v1/hub-sign-in/clients/${encodeURIComponent(clientId)}`,
492
+ });
493
+
494
+ it("answers a live client's name and origin, and nothing else", async () => {
495
+ const res = await read(spoke.spoke.id);
496
+ expect(res.statusCode).toBe(200);
497
+ expect(res.headers["cache-control"]).toBe("no-store");
498
+ expect(res.json()).toEqual({
499
+ ok: true,
500
+ data: {
501
+ clientId: spoke.spoke.id,
502
+ name: spoke.spoke.name,
503
+ origin: new URL(spoke.client.redirectUris[0]!).origin,
504
+ },
505
+ });
506
+ });
507
+
508
+ it("is a 404 for an unknown client id", async () => {
509
+ const res = await read("spoke_nope");
510
+ expect(res.statusCode).toBe(404);
511
+ expect(res.json()).toEqual({
512
+ ok: false,
513
+ error: { code: "not_found", message: "Not found" },
514
+ });
515
+ });
516
+
517
+ it("is the same 404 for a disabled client", async () => {
518
+ spoke.client.enabled = false;
519
+ const res = await read(spoke.spoke.id);
520
+ expect(res.statusCode).toBe(404);
521
+ expect(res.json()).toEqual({
522
+ ok: false,
523
+ error: { code: "not_found", message: "Not found" },
524
+ });
525
+ });
526
+
527
+ it("is the same 404 for a revoked spoke", async () => {
528
+ spoke.spoke.revokedAt = new Date();
529
+ const res = await read(spoke.spoke.id);
530
+ expect(res.statusCode).toBe(404);
531
+ });
532
+ });
@@ -35,11 +35,13 @@
35
35
  * `/v1/auth/tokens`; recorded in apps/api/CLAUDE.md).
36
36
  */
37
37
  import { logger } from "@working-theory/observability";
38
+ import { findSignInClientInfo } from "../../lib/hub-sign-in-store.js";
38
39
  import {
39
40
  hubSignInCodeResponseSchema,
40
41
  hubSignInMeResponseSchema,
41
42
  hubSignInRevokeResponseSchema,
42
43
  hubSignInTokenResponseSchema,
44
+ hubSignInClientInfoResponseSchema,
43
45
  } from "@working-theory/validation";
44
46
  import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
45
47
  import type { ZodType } from "zod";
@@ -159,6 +161,45 @@ export const hubSignInSessionRoutes: FastifyPluginAsync = async (fastify) => {
159
161
  });
160
162
  };
161
163
 
164
+ /**
165
+ * `clients/:clientId` — public, no auth (#8790 D2). The hub's own sign-in page
166
+ * asks what it may say about the spoke that sent a visitor: the spoke's name
167
+ * and origin, from the registry. A missing, disabled, or revoked client is
168
+ * a 404 with the same body as an unknown one, so the route confirms nothing
169
+ * about ids that are not live. Registered in `server.ts`'s public block.
170
+ */
171
+ export const hubSignInPublicRoutes: FastifyPluginAsync = async (fastify) => {
172
+ fastify.get<{ Params: { clientId: string } }>(
173
+ "/v1/hub-sign-in/clients/:clientId",
174
+ async (request, reply) => {
175
+ const { clientId } = request.params;
176
+ reply.header("Cache-Control", "no-store");
177
+ if (clientId.length === 0 || clientId.length > 128) {
178
+ return notFound(reply);
179
+ }
180
+ const info = await findSignInClientInfo(clientId);
181
+ if (info === null) return notFound(reply);
182
+ const parsed = hubSignInClientInfoResponseSchema.safeParse(info);
183
+ if (!parsed.success) {
184
+ logger.error(
185
+ {
186
+ route: "hub-sign-in/clients",
187
+ issues: parsed.error.issues.map((i) => i.path.join(".")),
188
+ },
189
+ "[hub-sign-in] response failed its own contract schema",
190
+ );
191
+ return error(
192
+ reply,
193
+ 500,
194
+ "internal_error",
195
+ "Response failed contract validation",
196
+ );
197
+ }
198
+ return reply.status(200).send({ ok: true, data: parsed.data });
199
+ },
200
+ );
201
+ };
202
+
162
203
  /** `token` and `revoke` — registered in their own rate-limited scope. */
163
204
  export const hubSignInClientRoutes: FastifyPluginAsync = async (fastify) => {
164
205
  fastify.post(