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
@@ -4,32 +4,73 @@
4
4
  * `?t=` token never leaks through a Referer header off this page (D17,
5
5
  * SEC-8438-9).
6
6
  *
7
- * RSC: reads `?t=` and renders the heading, the lead and the one
7
+ * RSC: reads `?t=` and renders the heading, the line and the one
8
8
  * interactive piece (`UnsubscribeButton`, "use client") that posts
9
9
  * `unsubscribeAction(token)` ON CLICK — never on this GET, so a mail
10
- * scanner's prefetch writes nothing. The island gets the token and nothing
11
- * else: it reads its own strings, because anything callable handed across
12
- * this server→client edge fails Flight serialization (the scaffold matrix's
13
- * `GET /unsubscribe -> 500`; `__tests__/unsubscribe-rsc-boundary.test.tsx`).
10
+ * scanner's prefetch writes nothing. The island gets the token and three
11
+ * strings: anything callable handed across this server→client edge fails
12
+ * Flight serialization (the scaffold matrix's `GET /unsubscribe -> 500`;
13
+ * `__tests__/unsubscribe-rsc-boundary.test.tsx`).
14
+ *
15
+ * #8847 D12 — the title, line, button and done message come from the
16
+ * newsletter magnet's `unsubscribe` block (`readServedMagnet`, shared like
17
+ * /contact's read), else the seeded `newsletterFlowConfig`'s. The token states
18
+ * (already, invalid, unavailable) keep their catalog lines: they describe
19
+ * the link, not the newsletter. The frame is /contact's `column` look. The
20
+ * page renders on every request.
14
21
  */
15
22
 
23
+ import { SectionFrame } from "@working-theory/ui";
16
24
  import { Heading, Text } from "@working-theory/ui/primitives";
25
+ import {
26
+ NEWSLETTER_FLOW_SLUG,
27
+ newsletterFlowConfig,
28
+ resolveLocalizedString,
29
+ type FlowUnsubscribeCopy,
30
+ } from "@working-theory/validation";
17
31
  import type { Metadata } from "next";
18
- import { getTranslations } from "next-intl/server";
19
32
  import type { ReactElement } from "react";
20
33
 
34
+ import { readServedMagnet } from "../../../lib/in-page-magnet";
35
+
21
36
  import { UnsubscribeButton } from "./unsubscribe-button";
22
37
 
38
+ export const dynamic = "force-dynamic";
39
+
23
40
  interface Props {
24
41
  params: Promise<{ locale: string }>;
25
42
  searchParams: Promise<{ t?: string }>;
26
43
  }
27
44
 
45
+ interface UnsubscribeCopy {
46
+ title: string;
47
+ body?: string;
48
+ cta: string;
49
+ doneHeadline: string;
50
+ doneBody?: string;
51
+ }
52
+
53
+ /** The newsletter magnet's unsubscribe copy, else the seeded config's. */
54
+ async function unsubscribeCopy(locale: string): Promise<UnsubscribeCopy> {
55
+ const served = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
56
+ const config = served?.unsubscribe ? served : newsletterFlowConfig;
57
+ const block = config.unsubscribe as FlowUnsubscribeCopy;
58
+ const resolve = (value: Record<string, string>): string =>
59
+ resolveLocalizedString(value, locale, config.defaultLocale);
60
+ return {
61
+ title: resolve(block.title),
62
+ ...(block.body !== undefined && { body: resolve(block.body) }),
63
+ cta: resolve(block.cta),
64
+ doneHeadline: resolve(block.done.headline),
65
+ ...(block.done.body !== undefined && { doneBody: resolve(block.done.body) }),
66
+ };
67
+ }
68
+
28
69
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
29
70
  const { locale } = await params;
30
- const t = await getTranslations({ locale, namespace: "unsubscribe" });
71
+ const copy = await unsubscribeCopy(locale);
31
72
  return {
32
- title: t("title"),
73
+ title: copy.title,
33
74
  robots: { index: false, follow: true },
34
75
  referrer: "no-referrer",
35
76
  };
@@ -38,17 +79,28 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
38
79
  export default async function UnsubscribePage({ params, searchParams }: Props): Promise<ReactElement> {
39
80
  const { locale } = await params;
40
81
  const { t: token } = await searchParams;
41
- const t = await getTranslations({ locale, namespace: "unsubscribe" });
82
+ const copy = await unsubscribeCopy(locale);
42
83
 
43
84
  return (
44
- <div className="mx-auto flex max-w-md flex-col gap-4 px-4 py-16" data-testid="unsubscribe-page">
45
- <Heading level={1} visual={2}>
46
- {t("title")}
47
- </Heading>
48
- <Text as="p" variant="body">
49
- {t("lead")}
50
- </Text>
51
- <UnsubscribeButton token={token ?? null} />
52
- </div>
85
+ <SectionFrame sectionId="unsubscribe" className="ailk-contact-frame" data-testid="unsubscribe-page">
86
+ <div className="ailk-contact-column text-center">
87
+ <Heading level={1} visual="display" className="ailk-contact-heading text-center">
88
+ {copy.title}
89
+ </Heading>
90
+ {copy.body !== undefined && (
91
+ <Text as="p" tone="muted" className="mx-auto mt-block-tight ailk-contact-measure text-balance">
92
+ {copy.body}
93
+ </Text>
94
+ )}
95
+ <div className="ailk-contact-slot mx-auto mt-block-loose flex w-full ailk-contact-measure flex-col items-center">
96
+ <UnsubscribeButton
97
+ token={token ?? null}
98
+ cta={copy.cta}
99
+ doneHeadline={copy.doneHeadline}
100
+ {...(copy.doneBody !== undefined && { doneBody: copy.doneBody })}
101
+ />
102
+ </div>
103
+ </div>
104
+ </SectionFrame>
53
105
  );
54
106
  }
@@ -5,12 +5,13 @@
5
5
  * D17). Posts `unsubscribeAction(token)` on click only — never on render,
6
6
  * so a mail scanner's GET prefetch of the page writes nothing.
7
7
  *
8
- * Reads its own strings through `useTranslations` (the island idiom
9
- * `FlowStepperClient` uses) rather than taking them as props: the done line
10
- * needs the purpose's description formatted in, and a formatting FUNCTION
11
- * handed across from the page (a Server Component) cannot be serialized
12
- * into the Flight payload — the scaffold matrix's `GET /unsubscribe -> 500`
13
- * (`unsubscribe-rsc-boundary.test.tsx` holds the regression).
8
+ * The button label and the done message are the newsletter magnet's
9
+ * (#8847 D12), passed as STRINGS: a formatting FUNCTION handed across from the
10
+ * page (a Server Component) cannot be serialized into the Flight payload —
11
+ * the scaffold matrix's `GET /unsubscribe -> 500`
12
+ * (`unsubscribe-rsc-boundary.test.tsx` holds the regression). The token
13
+ * states (invalid, already, unavailable) read their own catalog lines
14
+ * through `useTranslations`.
14
15
  */
15
16
 
16
17
  import { Button, InlineMessage } from "@working-theory/ui/primitives";
@@ -19,7 +20,21 @@ import { useState, useTransition, type ReactElement } from "react";
19
20
 
20
21
  import { unsubscribeAction, type UnsubscribeResult } from "./unsubscribe.actions";
21
22
 
22
- export function UnsubscribeButton({ token }: { token: string | null }): ReactElement {
23
+ interface UnsubscribeButtonProps {
24
+ token: string | null;
25
+ /** The button label (the magnet's `unsubscribe.cta`). */
26
+ cta: string;
27
+ /** The done message (the magnet's `unsubscribe.done`). */
28
+ doneHeadline: string;
29
+ doneBody?: string;
30
+ }
31
+
32
+ export function UnsubscribeButton({
33
+ token,
34
+ cta,
35
+ doneHeadline,
36
+ doneBody,
37
+ }: UnsubscribeButtonProps): ReactElement {
23
38
  const t = useTranslations("unsubscribe");
24
39
  const [result, setResult] = useState<UnsubscribeResult | null>(null);
25
40
  const [pending, startTransition] = useTransition();
@@ -35,7 +50,13 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
35
50
  if (result?.kind === "done") {
36
51
  return (
37
52
  <InlineMessage variant="success" role="status" data-testid="unsubscribe-done">
38
- {t("done", { description: result.description })}
53
+ {/* Headline over body, as the /contact ending stacks them (#8847 D12). */}
54
+ <span className="flex flex-col">
55
+ <span className="font-semibold" data-testid="unsubscribe-done-headline">
56
+ {doneHeadline}
57
+ </span>
58
+ {doneBody !== undefined && <span className="mt-1">{doneBody}</span>}
59
+ </span>
39
60
  </InlineMessage>
40
61
  );
41
62
  }
@@ -67,7 +88,7 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
67
88
  }}
68
89
  data-testid="unsubscribe-button"
69
90
  >
70
- {t("button")}
91
+ {cta}
71
92
  </Button>
72
93
  );
73
94
  }
@@ -62,7 +62,7 @@ describe("whitepapers/[slug] — snapshot regression", () => {
62
62
  });
63
63
  const { container } = render(element);
64
64
  expect(container.innerHTML).toMatchInlineSnapshot(`
65
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
65
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
66
66
 
67
67
  Body content.</div></div></article></div></div></div></main>"
68
68
  `);
@@ -10,6 +10,10 @@
10
10
  * are the two external boundaries; both are mocked. The route's own decisions
11
11
  * are what is under test: which query it accepts, who it sends to sign in,
12
12
  * where a minted code goes, and what it refuses to redirect to.
13
+ *
14
+ * #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
15
+ * `email` on the no-session branch only; it never enters the continuation ref
16
+ * or the code redirect, and a repeated one is a 400 like any repeated key.
13
17
  */
14
18
 
15
19
  // --- Mocks set up BEFORE imports ---
@@ -345,3 +349,113 @@ describe("a signed-in person", () => {
345
349
  },
346
350
  );
347
351
  });
352
+
353
+ describe("the login_hint (#8757 D9)", () => {
354
+ const HINT = "a@b.example";
355
+
356
+ /** The `next` value of the 303, and the ref inside it. */
357
+ function continuationOf(res: Response): { next: string; ref: string } {
358
+ const location = res.headers.get("location") ?? "";
359
+ const next = new URL(location, HUB).searchParams.get("next") ?? "";
360
+ return { next, ref: next.slice(`${CONTINUE}/`.length) };
361
+ }
362
+
363
+ it("no session + a valid login_hint: 303 to /sign-in?next=<continue path>&email=a%40b.example; the ref opens to the six-key canonical query alone", async () => {
364
+ mockGetSession.mockResolvedValue(null);
365
+ const canonical = query();
366
+
367
+ const res = await GET(
368
+ request(`${canonical}&login_hint=${encodeURIComponent(HINT)}`),
369
+ );
370
+
371
+ expect(res.status).toBe(303);
372
+ const { next, ref } = continuationOf(res);
373
+ expect(res.headers.get("location")).toBe(
374
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}&email=a%40b.example`,
375
+ );
376
+ expect(next).toBe(`${CONTINUE}/${ref}`);
377
+ const opened = openContinuation(ref, mockSecret);
378
+ expect(opened).toBe(canonical);
379
+ expect(opened).not.toContain("login_hint");
380
+ expect(opened).not.toContain("email");
381
+ expect(mockCreateCode).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it.each([
385
+ ["an invalid login_hint", "nope"],
386
+ ["a login_hint carrying a separator", "a@b.example&next=/x"],
387
+ ["an over-long login_hint", `${"a".repeat(250)}@b.example`],
388
+ ])(
389
+ "no session + %s: dropped — the 303 carries no email",
390
+ async (_name, hint) => {
391
+ mockGetSession.mockResolvedValue(null);
392
+ const canonical = query();
393
+
394
+ const res = await GET(
395
+ request(`${canonical}&login_hint=${encodeURIComponent(hint)}`),
396
+ );
397
+
398
+ expect(res.status).toBe(303);
399
+ const { ref } = continuationOf(res);
400
+ expect(res.headers.get("location")).toBe(
401
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
402
+ );
403
+ expect(openContinuation(ref, mockSecret)).toBe(canonical);
404
+ },
405
+ );
406
+
407
+ it.each([
408
+ ["no session", null],
409
+ ["a session", { user: { id: "u1" } }],
410
+ ])(
411
+ "a repeated login_hint is a 400 invalid_request (%s)",
412
+ async (_name, session) => {
413
+ mockGetSession.mockResolvedValue(session);
414
+
415
+ const res = await GET(
416
+ request(`${query()}&login_hint=a%40b.example&login_hint=c%40d.example`),
417
+ );
418
+
419
+ expect(res.status).toBe(400);
420
+ expect(res.headers.get("location")).toBeNull();
421
+ expect(await res.json()).toEqual({ error: "invalid_request" });
422
+ expect(mockCreateCode).not.toHaveBeenCalled();
423
+ expect(mockGetSession).not.toHaveBeenCalled();
424
+ },
425
+ );
426
+
427
+ it("with a session: the code redirect is unchanged and carries no hint", async () => {
428
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
429
+ mockCreateCode.mockResolvedValue({
430
+ ok: true,
431
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
432
+ });
433
+
434
+ const withHint = await GET(
435
+ request(`${query()}&login_hint=${encodeURIComponent(HINT)}`, {
436
+ cookie: "better-auth.session_token=tok",
437
+ }),
438
+ );
439
+ const without = await GET(
440
+ request(query(), { cookie: "better-auth.session_token=tok" }),
441
+ );
442
+
443
+ expect(withHint.status).toBe(303);
444
+ const location = withHint.headers.get("location") ?? "";
445
+ expect(location).toBe(without.headers.get("location"));
446
+ expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
447
+ expect(location).not.toContain("login_hint");
448
+ expect(location).not.toContain("email");
449
+ expect(location).not.toContain("b.example");
450
+ // The hint is never handed to the API either.
451
+ expect(mockCreateCode).toHaveBeenNthCalledWith(
452
+ 1,
453
+ {
454
+ clientId: "spoke_abc",
455
+ redirectUri: SPOKE_CALLBACK,
456
+ codeChallenge: CHALLENGE,
457
+ },
458
+ { headers: { cookie: "better-auth.session_token=tok", origin: HUB } },
459
+ );
460
+ });
461
+ });
@@ -11,6 +11,7 @@
11
11
  * GET /api/auth/hub/authorize
12
12
  * ?response_type=code&client_id=…&redirect_uri=…&state=…
13
13
  * &code_challenge=…&code_challenge_method=S256
14
+ * [&login_hint=<email>] (optional, #8757 D9 — see below)
14
15
  *
15
16
  * - a malformed query is a 400 and NEVER a redirect;
16
17
  * - no Better Auth session: the six validated keys are sealed into a short
@@ -32,6 +33,14 @@
32
33
  * - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
33
34
  * visitor copy: only a misconfigured client reaches it.
34
35
  *
36
+ * `login_hint` (#8757 D9) is optional and read beside the six keys with
37
+ * `accountEmailHintSchema` (`hubAuthorizeQuerySchema` is unchanged): repeated
38
+ * → 400 like any repeated key; refused → dropped. It acts only on the
39
+ * no-session branch, as the sign-in page's `email`, so the form opens with the
40
+ * address typed. It never enters the ref, a code or a cookie — the continue
41
+ * route rebuilds the query from the ref, so the hint is gone after sign-in —
42
+ * and with a session it is ignored.
43
+ *
35
44
  * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
36
45
  * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
37
46
  * origin, exactly as the emailed link does.
@@ -42,7 +51,10 @@
42
51
  * state, a challenge or a cookie.
43
52
  */
44
53
  import { hubSignIn } from "@working-theory/api-client";
45
- import { hubAuthorizeQuerySchema } from "@working-theory/validation";
54
+ import {
55
+ accountEmailHintSchema,
56
+ hubAuthorizeQuerySchema,
57
+ } from "@working-theory/validation";
46
58
 
47
59
  import { auth, resolveSecret } from "../../../../../auth";
48
60
  import {
@@ -109,9 +121,13 @@ export async function GET(request: Request): Promise<Response> {
109
121
  if (values.length > 1) return json(400, "invalid_request");
110
122
  raw[key] = values[0];
111
123
  }
124
+ // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
125
+ const hints = url.searchParams.getAll("login_hint");
126
+ if (hints.length > 1) return json(400, "invalid_request");
112
127
  const parsed = hubAuthorizeQuerySchema.safeParse(raw);
113
128
  if (!parsed.success) return json(400, "invalid_request");
114
129
  const query = parsed.data;
130
+ const hint = accountEmailHintSchema.safeParse(hints[0]);
115
131
 
116
132
  const session = await auth.api.getSession({ headers: request.headers });
117
133
  if (!session) {
@@ -121,8 +137,9 @@ export async function GET(request: Request): Promise<Response> {
121
137
  for (const key of QUERY_KEYS) canonical.set(key, query[key]);
122
138
  const ref = mintContinuation(canonical.toString(), resolveSecret());
123
139
  if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
124
- const next = `${CONTINUE_PATH}/${ref}`;
125
- return see(`/sign-in?next=${encodeURIComponent(next)}`);
140
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
141
+ if (hint.success) signIn.set("email", hint.data);
142
+ return see(`/sign-in?${signIn.toString()}`);
126
143
  }
127
144
 
128
145
  const origin = pinnedOrigin();
@@ -13,6 +13,9 @@
13
13
  * a callback that BURNS a code it cannot complete (AC-5), and a sign-out that
14
14
  * refuses a cross-site request and revokes nothing (AC-5). Each route is a 404
15
15
  * unless the web seam's `SIGN_IN_PROVIDER` is `"hub"` (AC-9).
16
+ *
17
+ * #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
18
+ * authorize URL's `login_hint`; the sealed flow cookie never carries it.
16
19
  */
17
20
 
18
21
  import { createHash } from "node:crypto";
@@ -36,7 +39,7 @@ jest.mock("../../../../lib/topology.generated", () => ({
36
39
  },
37
40
  }));
38
41
 
39
- import { seal } from "../../../../lib/hub-session/seal";
42
+ import { seal, unseal } from "../../../../lib/hub-session/seal";
40
43
  import { GET as callback } from "../callback/route";
41
44
  import { GET as signIn } from "../sign-in/route";
42
45
  import { POST as signOut } from "../sign-out/route";
@@ -161,6 +164,46 @@ describe("GET /api/hub-session/sign-in", () => {
161
164
  });
162
165
  });
163
166
 
167
+ describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
168
+ function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
169
+ const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
170
+ expect(res.status).toBe(303);
171
+ const flow = cookieValue(res, "ailk-hub-flow");
172
+ if (flow === undefined) throw new Error("no flow cookie");
173
+ const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
174
+ if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
175
+ return {
176
+ authorize: new URL(res.headers.get("location") ?? ""),
177
+ payload: payload as Record<string, unknown>,
178
+ };
179
+ }
180
+
181
+ it("?next=/account&email=a%40b.example → the authorize URL carries login_hint; the flow cookie does not", () => {
182
+ const { authorize, payload } = startWith("next=/account&email=a%40b.example");
183
+ expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
184
+ expect(authorize.search).toContain("login_hint=a%40b.example");
185
+ // The six keys are untouched beside it.
186
+ expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
187
+ expect(authorize.searchParams.getAll("login_hint")).toHaveLength(1);
188
+ expect(payload.next).toBe("/account");
189
+ expect(payload).not.toHaveProperty("email");
190
+ expect(payload).not.toHaveProperty("login_hint");
191
+ expect(JSON.stringify(payload)).not.toContain("a@b.example");
192
+ });
193
+
194
+ it.each([
195
+ ["an invalid value", "next=/account&email=bad"],
196
+ ["a repeated value", "next=/account&email=a%40b.example&email=c%40d.example"],
197
+ ["a value carrying a separator", "next=/account&email=a%40b.example%26x%3D1"],
198
+ ["no value", "next=/account"],
199
+ ])("%s → no login_hint", (_label, query) => {
200
+ const { authorize, payload } = startWith(query);
201
+ expect(authorize.searchParams.has("login_hint")).toBe(false);
202
+ expect(payload).not.toHaveProperty("email");
203
+ expect(payload).not.toHaveProperty("login_hint");
204
+ });
205
+ });
206
+
164
207
  describe("GET /api/hub-session/callback", () => {
165
208
  function exchangeOk(): void {
166
209
  mockExchange.mockResolvedValue({
@@ -10,10 +10,17 @@
10
10
  * - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
11
11
  * - hub mode but not configured: 303 to `/account`, whose gate says so.
12
12
  *
13
+ * Hinted email (#8757 D9): a single `?email=` that `accountEmailHintSchema`
14
+ * accepts rides to the hub as `login_hint`, so its sign-in form opens with
15
+ * the address typed. A refused or repeated value is dropped. The hint goes on
16
+ * the authorize URL only — never into the sealed flow cookie.
17
+ *
13
18
  * Nothing here logs the state, the verifier or the challenge.
14
19
  */
15
20
  import { createHash, randomBytes } from "node:crypto";
16
21
 
22
+ import { accountEmailHintSchema } from "@working-theory/validation";
23
+
17
24
  import { hubSessionConfig } from "../../../../lib/hub-session/config";
18
25
  import { seal } from "../../../../lib/hub-session/seal";
19
26
  import {
@@ -40,13 +47,22 @@ function see(location: string, setCookie?: string): Response {
40
47
  return new Response(null, { status: 303, headers });
41
48
  }
42
49
 
50
+ /** #8757 D9 — the one `email` the hint schema accepts; repeated or refused → none. */
51
+ function emailHint(params: URLSearchParams): string | undefined {
52
+ const values = params.getAll("email");
53
+ if (values.length !== 1) return undefined;
54
+ const parsed = accountEmailHintSchema.safeParse(values[0]);
55
+ return parsed.success ? parsed.data : undefined;
56
+ }
57
+
43
58
  // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
44
59
  export function GET(request: Request): Response {
45
60
  if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
46
61
  const config = hubSessionConfig();
47
62
  if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
48
63
 
49
- const next = hubNextPath(new URL(request.url).searchParams.get("next"));
64
+ const params = new URL(request.url).searchParams;
65
+ const next = hubNextPath(params.get("next"));
50
66
  const state = randomBytes(32).toString("base64url");
51
67
  const verifier = randomBytes(32).toString("base64url");
52
68
  const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -60,14 +76,17 @@ export function GET(request: Request): Response {
60
76
  );
61
77
 
62
78
  const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
63
- authorize.search = new URLSearchParams({
79
+ const query = new URLSearchParams({
64
80
  response_type: "code",
65
81
  client_id: config.clientId,
66
82
  redirect_uri: config.redirectUri,
67
83
  state,
68
84
  code_challenge: challenge,
69
85
  code_challenge_method: "S256",
70
- }).toString();
86
+ });
87
+ const loginHint = emailHint(params);
88
+ if (loginHint !== undefined) query.set("login_hint", loginHint);
89
+ authorize.search = query.toString();
71
90
 
72
91
  return see(
73
92
  authorize.toString(),