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
@@ -0,0 +1,202 @@
1
+ "use server";
2
+
3
+ /**
4
+ * subscribeNewsletterAction — Server Action paired with the
5
+ * `ailkSubscribeNewsletter` MCP tool (#6730 D9).
6
+ *
7
+ * `/newsletter` and the footer render the newsletter magnet's
8
+ * `NewsletterFormInline` bound to this action. Validates the address with
9
+ * the tool's own input schema, calls `POST /v1/leads/subscribe` through
10
+ * `@working-theory/api-client`, and maps the outcome the same way
11
+ * `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
12
+ * `unavailable`; any other non-2xx or thrown error → the generic `error`;
13
+ * only a 2xx is `success`.
14
+ *
15
+ * Visitor forwarding (#7394): forwards the visitor exactly as
16
+ * `captureLeadAction` does, through `visitorForwardHeaders` and the
17
+ * server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
18
+ * with one argument, as before.
19
+ *
20
+ * Through the magnet (#8847 D11): /newsletter, /unsubscribe's sibling footer
21
+ * signup and the page itself all render the workspace's `newsletter`
22
+ * magnet, so a signup goes through it. The action re-reads the magnet; a post
23
+ * without `consent=true` while that magnet has a consent field (or, with no
24
+ * magnet, ever) answers a `consent` field error and nothing is sent. When the
25
+ * magnet maps, the
26
+ * address and the consent go to `POST /v1/flows/newsletter/steps` — the step
27
+ * key, the email field's name and the consent field's purposes all come from
28
+ * the server's own read, never the post (SEC-8847-A) — which writes the lead,
29
+ * the consent ledger row and the magnet's welcome email. A 2xx that completes
30
+ * is success; a 503 `lead_capture_not_configured` or an unreachable API
31
+ * (status 0, 502, 504) is `unavailable`. Otherwise (a Free build has no flow
32
+ * route) it posts `leads.subscribe`, as before.
33
+ */
34
+
35
+ import { flows, leads, visitorForwardHeaders } from "@working-theory/api-client";
36
+ import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
37
+ import {
38
+ NEWSLETTER_FLOW_SLUG,
39
+ subscribeNewsletterInputSchema,
40
+ } from "@working-theory/validation";
41
+ import { cookies, headers } from "next/headers";
42
+ import { getLocale } from "next-intl/server";
43
+
44
+ import { checkRateLimit, clientIpFromHeaders } from "../src/lib/rate-limit";
45
+
46
+ import { consentVisitorHeader, readConsentVisitor } from "./consent-visitor";
47
+ import { inPageView, readServedMagnet } from "./in-page-magnet";
48
+
49
+ const GENERIC_ERROR = "Something went wrong, please try again.";
50
+ const CONSENT_REQUIRED = "Please accept the terms to subscribe.";
51
+ const RATE_LIMITED = "Too many signups from here. Please try again in a few minutes.";
52
+ // #8847 (security review) — the contact action's per-visitor budget: the hub
53
+ // sees this site's egress address on the step route, so one visitor must not
54
+ // spend the bucket every visitor shares.
55
+ const RATE_LIMIT_MAX_CALLS = 30;
56
+ const RATE_LIMIT_WINDOW_MS = 10 * 60 * 1000;
57
+
58
+ /** The API's 503 for a deployment with nowhere to keep a lead. */
59
+ function isNotConfigured(res: { status: number; body: unknown }): boolean {
60
+ return (
61
+ res.status === 503 &&
62
+ typeof res.body === "object" &&
63
+ res.body !== null &&
64
+ (res.body as { error?: unknown }).error === "lead_capture_not_configured"
65
+ );
66
+ }
67
+
68
+ /** An API that could not be reached: a thrown call (0), a 502 or a 504. */
69
+ function isApiUnreachable(res: { status: number }): boolean {
70
+ return res.status === 0 || res.status === 502 || res.status === 504;
71
+ }
72
+
73
+ /**
74
+ * The signup through the served newsletter magnet, or `null` when it does not
75
+ * map (absent, draft, a Free build with no flow route, or not a one-field
76
+ * signup) — the caller then posts `leads.subscribe`.
77
+ */
78
+ async function submitThroughMagnet(
79
+ email: string,
80
+ consented: boolean,
81
+ init: { headers: Record<string, string> } | undefined,
82
+ ): Promise<LeadCaptureFormState | null> {
83
+ const magnet = await readServedMagnet(NEWSLETTER_FLOW_SLUG);
84
+ let locale = "en";
85
+ try {
86
+ locale = await getLocale();
87
+ } catch {
88
+ // No request-scoped locale — the default locale.
89
+ }
90
+ const view = magnet ? inPageView(magnet, locale, "newsletter") : null;
91
+ if (view === null || !("kind" in view.form)) return null;
92
+ const { emailField, consent } = view.form;
93
+ // A magnet without a consent field (a wizard-built newsletter) asks for none.
94
+ if (consent !== undefined && !consented) {
95
+ return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
96
+ }
97
+ const values: Record<string, unknown> = { [emailField]: email };
98
+ if (consent !== undefined) {
99
+ for (const purpose of consent.purposes) values[`${consent.field}.${purpose}`] = true;
100
+ }
101
+ const payload = { stepKey: view.stepKey, values, locale };
102
+ try {
103
+ const res = init
104
+ ? await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload, init)
105
+ : await flows.submitStep(NEWSLETTER_FLOW_SLUG, payload);
106
+ if (res.ok) return res.body.completed ? { kind: "success" } : { kind: "error", message: GENERIC_ERROR };
107
+ if (isNotConfigured(res) || isApiUnreachable(res)) return { kind: "unavailable" };
108
+ return { kind: "error", message: GENERIC_ERROR };
109
+ } catch (err) {
110
+ // The api-client never attaches the request payload to a thrown error.
111
+ console.error("[subscribeNewsletterAction]", err);
112
+ return { kind: "unavailable" };
113
+ }
114
+ }
115
+
116
+ export async function subscribeNewsletterAction(
117
+ _prevState: LeadCaptureFormState,
118
+ formData: FormData,
119
+ ): Promise<LeadCaptureFormState> {
120
+ // Honeypot: a bot that fills the hidden field gets a person's answer, and
121
+ // nothing is sent.
122
+ const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
123
+ if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
124
+
125
+ const parsed = subscribeNewsletterInputSchema.safeParse({
126
+ email: formData.get("email"),
127
+ });
128
+ if (!parsed.success) {
129
+ return {
130
+ kind: "error",
131
+ fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
132
+ };
133
+ }
134
+
135
+ let requestHeaders: Awaited<ReturnType<typeof headers>> | undefined;
136
+ try {
137
+ requestHeaders = await headers();
138
+ } catch {
139
+ // No request scope to read — no forwarding, and the visitor is "unknown".
140
+ }
141
+
142
+ // No readable address: skip the per-visitor limit rather than put every
143
+ // visitor in one shared bucket (the contact action's rule).
144
+ const visitor = requestHeaders ? clientIpFromHeaders(requestHeaders) : "unknown";
145
+ if (
146
+ visitor !== "unknown" &&
147
+ !checkRateLimit(`newsletter:${visitor}`, RATE_LIMIT_MAX_CALLS, RATE_LIMIT_WINDOW_MS)
148
+ ) {
149
+ return { kind: "error", message: RATE_LIMITED };
150
+ }
151
+
152
+ let init: { headers: Record<string, string> } | undefined;
153
+ if (requestHeaders) {
154
+ const forwarded = visitorForwardHeaders(requestHeaders, process.env.AILK_VISITOR_FORWARD_KEY);
155
+ if (forwarded) init = { headers: forwarded };
156
+ }
157
+
158
+ // #8438 D12 — the banner's visitor id, so a later subscribe links its
159
+ // cookie choices to the client record. Absent or invalid → no header.
160
+ try {
161
+ const visitorId = readConsentVisitor(await cookies());
162
+ if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
163
+ } catch {
164
+ // No request scope to read — subscribe with no visitor-link header.
165
+ }
166
+
167
+ const consented = formData.get("consent") === "true";
168
+ // The step route keys its limit on the forwarded address, as the walk's
169
+ // action sends it (flow.actions.ts).
170
+ const stepInit =
171
+ visitor !== "unknown"
172
+ ? { headers: { "x-forwarded-for": visitor, ...(init?.headers ?? {}) } }
173
+ : init;
174
+ const viaMagnet = await submitThroughMagnet(parsed.data.email, consented, stepInit);
175
+ if (viaMagnet !== null) return viaMagnet;
176
+ // The Free path renders the seeded config, whose consent box gates the post.
177
+ if (!consented) return { kind: "error", fieldErrors: { consent: CONSENT_REQUIRED } };
178
+
179
+ const siteId = process.env.AILK_SITE_ID ?? "default";
180
+ const body = { siteId, source: "newsletter", email: parsed.data.email };
181
+ try {
182
+ const res = init
183
+ ? await leads.subscribe(body, init)
184
+ : await leads.subscribe(body);
185
+ if (!res.ok) {
186
+ const error =
187
+ typeof res.body === "object" && res.body !== null
188
+ ? (res.body as { error?: unknown }).error
189
+ : undefined;
190
+ if (res.status === 503 && error === "lead_capture_not_configured") {
191
+ return { kind: "unavailable" };
192
+ }
193
+ return { kind: "error", message: GENERIC_ERROR };
194
+ }
195
+ return { kind: "success" };
196
+ } catch (err) {
197
+ // The api-client never attaches the request payload to a thrown error,
198
+ // so logging `err` carries no subscriber address.
199
+ console.error("[subscribeNewsletterAction]", err);
200
+ return { kind: "error", message: GENERIC_ERROR };
201
+ }
202
+ }
@@ -0,0 +1,326 @@
1
+ /**
2
+ * The workspace shown set → the Pricing section's data (#8925, spec
3
+ * docs/specs/8925-map-the-workspace-shown-set-spec.md).
4
+ *
5
+ * One pure function, `offeringsPricing`, turns the Offerings shown set (spec
6
+ * 8876 D4, served by #8924) into two Pricing section fragments: one plan card
7
+ * per `ailkGroup: "plan"` Product and one add-on tile per `"addon"` Product,
8
+ * in the snapshot's order, each keyed by the Product's `{plan}` so `?plan=`
9
+ * (#8842) can match it. #8927 places the fragments into `/pricing`.
10
+ *
11
+ * This is the one place Stripe text crosses into page markup, so contract
12
+ * docs/specs/8879-the-offerings-security-contract-key-spec.md binds it:
13
+ * - T2 — every Stripe string is emitted as a plain text field, with format
14
+ * characters removed; nothing is parsed as HTML or Markdown (D8).
15
+ * - T3 — every link is `OFFERINGS_CHECKOUT_PATH` plus a lookup key that
16
+ * `parseLookupKey` accepts; no href is built from any other field (D5).
17
+ * - §3.7 — `yearly` reads as annual and `once` as one-time at render, never
18
+ * in the key (D2).
19
+ *
20
+ * The module holds no user-visible copy: the CTA label, the recommended label
21
+ * and the period phrasings arrive in `labels` (D9, customer-facing-i18n).
22
+ */
23
+ import {
24
+ parseLookupKey,
25
+ lookupKeyPeriodSchema,
26
+ workspaceShownSetSchema,
27
+ type LookupKeyPeriod,
28
+ type OfferingsPrice,
29
+ type OfferingsProduct,
30
+ type PageSection,
31
+ } from "@working-theory/validation";
32
+
33
+ /** The period words, read by name from their one home (#8876 AC-9). */
34
+ const PERIOD = lookupKeyPeriodSchema.enum;
35
+
36
+ type PricingSectionData = Extract<PageSection, { type: "pricing" }>["data"];
37
+ type PricingPlanCard = PricingSectionData["plans"][number];
38
+ /** `AddonCard` ignores `key`; it is carried so a tile names its `{plan}` too. */
39
+ type PricingAddonTile = NonNullable<PricingSectionData["addons"]>[number] & {
40
+ key: string;
41
+ };
42
+
43
+ export interface OfferingsPricingLabels {
44
+ /** The CTA label on every card and tile. */
45
+ cta: string;
46
+ /** The recommended plan's pill and the recommended add-on's eyebrow. */
47
+ recommended: string;
48
+ /** A formatted amount → its monthly, annual and one-time phrasings. */
49
+ monthly(amount: string): string;
50
+ annual(amount: string): string;
51
+ oneTime(amount: string): string;
52
+ }
53
+
54
+ export interface OfferingsPricing {
55
+ plans: PricingSectionData | null;
56
+ addons: PricingSectionData | null;
57
+ }
58
+
59
+ /** The route every Offerings CTA links to, followed by a grammar key. */
60
+ export const OFFERINGS_CHECKOUT_PATH = "/api/offerings/checkout";
61
+
62
+ /**
63
+ * The CTA target for `lookupKey`, or `null` when the key is outside the
64
+ * grammar. A grammar key is `[a-z0-9_-]` only, so it needs no encoding.
65
+ */
66
+ export function offeringsCheckoutHref(lookupKey: string): string | null {
67
+ return parseLookupKey(lookupKey)
68
+ ? `${OFFERINGS_CHECKOUT_PATH}/${lookupKey}`
69
+ : null;
70
+ }
71
+
72
+ /**
73
+ * Whether `lookupKey` is a grammar key of a Price in a current, non-empty
74
+ * shown set — the keys `/pricing`'s cards are built from (#9051 D4).
75
+ */
76
+ export function isShownLookupKey(
77
+ shownSet: unknown,
78
+ lookupKey: string,
79
+ ): boolean {
80
+ if (!parseLookupKey(lookupKey)) return false;
81
+ const parsed = shownSetSchema.safeParse(shownSet);
82
+ if (!parsed.success) return false;
83
+ const { state, offerings } = parsed.data;
84
+ if (state !== "current" || offerings.length === 0) return false;
85
+ return offerings.some((o) => o.prices.some((p) => p.lookupKey === lookupKey));
86
+ }
87
+
88
+ // Stripe's zero- and three-decimal currencies. The spoke's legacy buy route
89
+ // held the only other copy across apps/web and @working-theory/spoke-checkout,
90
+ // and is retired (#9142). The table stays here, not in that package: a hub and
91
+ // a spoke without commerce carry /pricing but not the package.
92
+ const ZERO_DECIMAL_CURRENCIES = new Set([
93
+ "bif",
94
+ "clp",
95
+ "djf",
96
+ "gnf",
97
+ "jpy",
98
+ "kmf",
99
+ "krw",
100
+ "mga",
101
+ "pyg",
102
+ "rwf",
103
+ "ugx",
104
+ "vnd",
105
+ "vuv",
106
+ "xaf",
107
+ "xof",
108
+ "xpf",
109
+ ]);
110
+ const THREE_DECIMAL_CURRENCIES = new Set(["bhd", "jod", "kwd", "omr", "tnd"]);
111
+
112
+ function currencyExponent(currency: string): number {
113
+ if (ZERO_DECIMAL_CURRENCIES.has(currency)) return 0;
114
+ if (THREE_DECIMAL_CURRENCIES.has(currency)) return 3;
115
+ return 2;
116
+ }
117
+
118
+ /**
119
+ * An amount in Stripe's minor units, formatted for `locale` (D7). A whole
120
+ * amount drops its fraction digits; a locale `Intl` refuses falls back to `en`.
121
+ */
122
+ export function formatMinorUnits(
123
+ amount: number,
124
+ currency: string,
125
+ locale: string,
126
+ ): string {
127
+ const code = currency.toLowerCase();
128
+ const exponent = currencyExponent(code);
129
+ const value = amount / 10 ** exponent;
130
+ const options: Intl.NumberFormatOptions = {
131
+ style: "currency",
132
+ currency: code.toUpperCase(),
133
+ maximumFractionDigits: exponent,
134
+ minimumFractionDigits: Number.isInteger(value) ? 0 : exponent,
135
+ };
136
+ let format: Intl.NumberFormat;
137
+ try {
138
+ format = new Intl.NumberFormat(locale, options);
139
+ } catch {
140
+ format = new Intl.NumberFormat("en", options);
141
+ }
142
+ return format.format(value);
143
+ }
144
+
145
+ // Cc, Cf, Zl, Zp and Cs — the T1 classes. The input was normalized once by
146
+ // #8876, so this changes nothing there; it is the last guard before markup.
147
+ const FORMAT_CHARACTERS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}\p{Cs}]/gu;
148
+
149
+ function stripFormatCharacters(text: string, keepNewlines = false): string {
150
+ return text.replace(FORMAT_CHARACTERS, (ch) =>
151
+ keepNewlines && ch === "\n" ? ch : "",
152
+ );
153
+ }
154
+
155
+ function features(product: OfferingsProduct): string[] {
156
+ return product.marketingFeatures
157
+ .map((feature) => stripFormatCharacters(feature))
158
+ .filter((feature) => feature.length > 0);
159
+ }
160
+
161
+ function priceFor(
162
+ product: OfferingsProduct,
163
+ period: LookupKeyPeriod,
164
+ ): OfferingsPrice | undefined {
165
+ return product.prices.find((p) => p.period === period);
166
+ }
167
+
168
+ /** The Price a card shows in monthly mode: `monthly`, else `yearly`, else `once`. */
169
+ function shownPrice(product: OfferingsProduct): OfferingsPrice | undefined {
170
+ return (
171
+ priceFor(product, PERIOD.monthly) ??
172
+ priceFor(product, PERIOD.yearly) ??
173
+ priceFor(product, PERIOD.once)
174
+ );
175
+ }
176
+
177
+ function phrase(
178
+ price: OfferingsPrice,
179
+ locale: string,
180
+ labels: OfferingsPricingLabels,
181
+ ): string {
182
+ const amount = formatMinorUnits(price.amount, price.currency, locale);
183
+ if (price.period === PERIOD.monthly) return labels.monthly(amount);
184
+ if (price.period === PERIOD.yearly) return labels.annual(amount);
185
+ return labels.oneTime(amount);
186
+ }
187
+
188
+ /**
189
+ * Only the first `recommended` Product of a group is highlighted (D4). Each
190
+ * offering's own flag is read; the shown set's top-level `recommended` ids
191
+ * carry the same fact and are deliberately not consulted a second time.
192
+ */
193
+ function firstRecommended(products: OfferingsProduct[]): string | undefined {
194
+ return products.find((p) => p.recommended)?.plan;
195
+ }
196
+
197
+ function planCard(
198
+ product: OfferingsProduct,
199
+ highlighted: boolean,
200
+ locale: string,
201
+ labels: OfferingsPricingLabels,
202
+ ): PricingPlanCard | null {
203
+ const shown = shownPrice(product);
204
+ const href = shown ? offeringsCheckoutHref(shown.lookupKey) : null;
205
+ if (!href) return null;
206
+
207
+ const monthly = priceFor(product, PERIOD.monthly);
208
+ const yearly = priceFor(product, PERIOD.yearly);
209
+ const once = priceFor(product, PERIOD.once);
210
+ const price: PricingPlanCard["price"] = {};
211
+ if (monthly) price.monthly = phrase(monthly, locale, labels);
212
+ if (yearly) price.annual = phrase(yearly, locale, labels);
213
+ if (once && !monthly && !yearly) price.custom = phrase(once, locale, labels);
214
+
215
+ const card: PricingPlanCard = {
216
+ key: product.plan,
217
+ name: stripFormatCharacters(product.name),
218
+ price,
219
+ features: features(product),
220
+ cta: { label: labels.cta, href },
221
+ };
222
+ const description =
223
+ product.description && stripFormatCharacters(product.description, true);
224
+ if (description) card.description = description;
225
+ if (product.unitLabel)
226
+ card.priceUnit = stripFormatCharacters(product.unitLabel);
227
+ if (product.note) card.priceSubhead = stripFormatCharacters(product.note);
228
+ if (product.termsNote)
229
+ card.priceFootnote = stripFormatCharacters(product.termsNote);
230
+ // Annual mode shows the yearly Price only when a monthly one is beside it,
231
+ // so only then does the card need a second link (D5, D6).
232
+ const annualHref =
233
+ monthly && yearly && offeringsCheckoutHref(yearly.lookupKey);
234
+ if (annualHref) card.annualCtaHref = annualHref;
235
+ if (highlighted) {
236
+ card.highlighted = true;
237
+ card.highlightStyle = "pill";
238
+ card.highlightLabel = labels.recommended;
239
+ }
240
+ return card;
241
+ }
242
+
243
+ function addonTile(
244
+ product: OfferingsProduct,
245
+ highlighted: boolean,
246
+ locale: string,
247
+ labels: OfferingsPricingLabels,
248
+ ): PricingAddonTile | null {
249
+ const shown = shownPrice(product);
250
+ const href = shown && offeringsCheckoutHref(shown.lookupKey);
251
+ if (!shown || !href) return null;
252
+
253
+ const tile: PricingAddonTile = {
254
+ key: product.plan,
255
+ name: stripFormatCharacters(product.name),
256
+ price: phrase(shown, locale, labels),
257
+ features: features(product),
258
+ cta: { label: labels.cta, href },
259
+ };
260
+ if (product.unitLabel)
261
+ tile.qualifier = stripFormatCharacters(product.unitLabel);
262
+ if (highlighted) {
263
+ tile.eyebrow = labels.recommended;
264
+ tile.checkTone = "primary";
265
+ }
266
+ return tile;
267
+ }
268
+
269
+ // #8927 D3 — `GET /v1/content/shown-offerings` sends the shown set without
270
+ // `workspaceId` and `organizationId` (spec 8924 D3), which the strict schema
271
+ // requires. Both ids become optional; an id that is present is still
272
+ // validated, every other field is still required and an unknown key is still
273
+ // refused.
274
+ const shownSetSchema = workspaceShownSetSchema.partial({
275
+ workspaceId: true,
276
+ organizationId: true,
277
+ });
278
+
279
+ function isPresent<T>(value: T | null): value is T {
280
+ return value !== null;
281
+ }
282
+
283
+ /**
284
+ * The shown set as Pricing section data, or `null` when there is nothing to
285
+ * show: a value the shown-set schema refuses, a snapshot that is not
286
+ * `current`, or no offerings. Each fragment is `null` when its group is empty.
287
+ */
288
+ export function offeringsPricing(
289
+ shownSet: unknown,
290
+ options: { locale: string; labels: OfferingsPricingLabels },
291
+ ): OfferingsPricing | null {
292
+ const parsed = shownSetSchema.safeParse(shownSet);
293
+ if (!parsed.success) return null;
294
+ const { state, offerings } = parsed.data;
295
+ if (state !== "current" || offerings.length === 0) return null;
296
+ const { locale, labels } = options;
297
+
298
+ const planProducts = offerings.filter((o) => o.ailkGroup === "plan");
299
+ const addonProducts = offerings.filter((o) => o.ailkGroup === "addon");
300
+ const recommendedPlan = firstRecommended(planProducts);
301
+ const recommendedAddon = firstRecommended(addonProducts);
302
+
303
+ const plans = planProducts
304
+ .map((p) => planCard(p, p.plan === recommendedPlan, locale, labels))
305
+ .filter(isPresent);
306
+ const addons = addonProducts
307
+ .map((p) => addonTile(p, p.plan === recommendedAddon, locale, labels))
308
+ .filter(isPresent);
309
+
310
+ return {
311
+ plans:
312
+ plans.length > 0
313
+ ? {
314
+ variant: "three-tiers-with-toggle",
315
+ cardLayout: "centered",
316
+ billingToggle: plans.some(
317
+ (card) =>
318
+ card.price.monthly !== undefined &&
319
+ card.price.annual !== undefined,
320
+ ),
321
+ plans,
322
+ }
323
+ : null,
324
+ addons: addons.length > 0 ? { variant: "addons", plans: [], addons } : null,
325
+ };
326
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The posting a `/careers/<slug>/apply` page applies to, read by its title
3
+ * (#8840 D11). The read sits here, not in a page, so the structured-data walk
4
+ * does not take an apply page for a JobPosting page: the posting's own JSON-LD
5
+ * is `/careers/<slug>`'s. In `lib/` (#8849 D9), not a route directory, so
6
+ * every scaffold carries it: the `signal-collectors` walk and the Free apply
7
+ * page (`careers/[slug]/[action]`) both read it.
8
+ */
9
+ import { getMdxSource } from "@working-theory/page-renderer";
10
+
11
+ /** The posting's title in `locale`, or `null` when no posting has this slug. */
12
+ export async function postingTitle(slug: string, locale: string): Promise<string | null> {
13
+ const source = await getMdxSource("JobPosting", slug, locale);
14
+ if (source === null) return null;
15
+ const title = (source.frontmatter as { title?: unknown }).title;
16
+ return typeof title === "string" && title.trim() !== "" ? title : null;
17
+ }
@@ -9,6 +9,14 @@
9
9
  * `lib/license-key-store.ts`, which hands `prismaForAuth.siteSetting` to the
10
10
  * licence client's SiteSetting store for the ONE deployment-wide
11
11
  * `license.renewed_key` row (#6677 DD-6 — a narrow, named exception).
12
+ *
13
+ * A third use rides on `auth.ts`'s own (#8765 D4, SEC-8765-K):
14
+ * `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
15
+ * consent grants through `consentGrant.createMany` on the transaction client
16
+ * Better Auth's sign-up transaction runs on — only rows for the user that
17
+ * transaction is creating, never a read and never any other row. It imports
18
+ * nothing from here; it reaches the client through the adapter.
19
+ *
12
20
  * Every other business-logic caller goes through apps/api, never the
13
21
  * database directly (architecture.yaml `apps/web` may_not_import_from
14
22
  * `database`; the single-file singleton here is the documented exception,