create-ailk 0.22.0 → 0.24.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 (287) hide show
  1. package/README.md +27 -14
  2. package/component-catalog.json +149 -5
  3. package/component-catalog.md +7 -6
  4. package/dist/add-module.js +17 -10
  5. package/dist/add-route.d.ts +55 -1
  6. package/dist/add-route.js +301 -13
  7. package/dist/cli.js +27 -10
  8. package/dist/derive-architecture.d.ts +7 -5
  9. package/dist/derive-architecture.js +26 -32
  10. package/dist/lib/design-checks.js +4 -2
  11. package/dist/lib/env-catalog.js +0 -4
  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 +36 -2
  15. package/dist/lib/scaffold-render-sweep.js +50 -8
  16. package/dist/module-architecture.js +115 -10
  17. package/dist/paid-sections.d.ts +21 -2
  18. package/dist/paid-sections.js +69 -7
  19. package/dist/seed-flows.d.ts +4 -1
  20. package/dist/seed-flows.js +50 -4
  21. package/dist/spoke-checkout.d.ts +38 -35
  22. package/dist/spoke-checkout.js +66 -229
  23. package/dist/starter-grid.d.ts +0 -3
  24. package/dist/starter-grid.js +24 -14
  25. package/dist/surfaces.js +33 -12
  26. package/dist/sync-routes.d.ts +68 -5
  27. package/dist/sync-routes.js +455 -29
  28. package/dist/template-record.d.ts +1 -1
  29. package/dist/template-record.js +3 -2
  30. package/dist/update.js +10 -0
  31. package/package.json +3 -3
  32. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  33. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  35. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  38. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  40. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  41. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  43. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  44. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  46. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  47. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  48. package/templates/.env.example +13 -7
  49. package/templates/.github/workflows/ci.yml +13 -0
  50. package/templates/CLAUDE.md +4 -2
  51. package/templates/README.md +1 -0
  52. package/templates/_gitignore +4 -0
  53. package/templates/apps/api/.env.example +34 -0
  54. package/templates/apps/api/CLAUDE.md +94 -19
  55. package/templates/apps/api/package.json +1 -0
  56. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  57. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  58. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  59. package/templates/apps/api/src/config/index.ts +109 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +7 -1
  61. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  62. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  63. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  64. package/templates/apps/api/src/lib/site.ts +4 -1
  65. package/templates/apps/api/src/lib/stripe.ts +77 -4
  66. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  67. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  68. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  69. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  70. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  71. package/templates/apps/api/src/openapi/spec.ts +343 -7
  72. package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -0
  73. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  74. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1426 -0
  75. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  76. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  77. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  78. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  79. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  80. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  81. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  82. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  83. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  84. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  85. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  87. package/templates/apps/api/src/routes/content/index.ts +16 -2
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  89. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  90. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  91. package/templates/apps/api/src/routes/content/search.ts +16 -45
  92. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  93. package/templates/apps/api/src/routes/leads/index.ts +377 -308
  94. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  95. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  96. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  97. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  98. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  99. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  100. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  101. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +6 -5
  102. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  103. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  104. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  105. package/templates/apps/api/src/routes/webhooks/stripe.ts +93 -17
  106. package/templates/apps/api/src/server.ts +165 -51
  107. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  108. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  109. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -2
  110. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  111. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  112. package/templates/apps/api/src/services/__tests__/person.test.ts +260 -1
  113. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
  114. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  115. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  116. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  117. package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
  118. package/templates/apps/api/src/services/entitlements.ts +157 -1
  119. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  120. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +348 -0
  121. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  122. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +975 -0
  123. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  124. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +400 -0
  125. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
  126. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  127. package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
  128. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
  129. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  130. package/templates/apps/api/src/services/offerings/connection.ts +1083 -0
  131. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  132. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  133. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  134. package/templates/apps/api/src/services/person.ts +147 -9
  135. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  136. package/templates/apps/api/src/services/project-listings.ts +43 -31
  137. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  138. package/templates/apps/mcp/CLAUDE.md +32 -18
  139. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  140. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  141. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  142. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  143. package/templates/apps/mcp/__tests__/http-transport.test.ts +95 -14
  144. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  145. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  146. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  147. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  148. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  149. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  150. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  151. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  152. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  153. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  154. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  155. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  156. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  157. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  158. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  159. package/templates/apps/mcp/package.json +1 -0
  160. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  161. package/templates/apps/mcp/src/http-transport.ts +47 -9
  162. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  163. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  164. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  165. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  166. package/templates/apps/mcp/src/server.ts +5 -0
  167. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  168. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  169. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  170. package/templates/apps/mcp/src/tools/index.ts +22 -21
  171. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  172. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  173. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  174. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  175. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  176. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  177. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  178. package/templates/apps/web/.env.example +10 -31
  179. package/templates/apps/web/CLAUDE.md +324 -42
  180. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  181. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  182. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  183. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  184. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  185. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  186. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  187. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  188. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  189. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  190. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  191. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  192. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  193. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  194. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  195. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  196. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  197. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +20 -6
  198. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  199. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  200. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  201. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  202. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  203. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  204. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  205. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  206. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +369 -0
  207. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  208. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  209. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  210. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  211. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  212. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  213. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  214. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  215. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  216. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  217. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  218. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  219. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  220. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  221. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  222. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  223. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  224. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  225. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  226. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  227. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +64 -0
  228. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  229. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  230. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  231. package/templates/apps/web/lib/auth-routes.ts +36 -2
  232. package/templates/apps/web/lib/checkout-config.ts +9 -20
  233. package/templates/apps/web/lib/deck-page.ts +1 -0
  234. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  235. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  236. package/templates/apps/web/lib/magnet-routes.ts +44 -0
  237. package/templates/apps/web/lib/modules.generated.ts +9 -0
  238. package/templates/apps/web/lib/newsletter.actions.ts +205 -0
  239. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  240. package/templates/apps/web/lib/posting-title.ts +17 -0
  241. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  242. package/templates/apps/web/messages/en.json +49 -15
  243. package/templates/apps/web/middleware.ts +12 -2
  244. package/templates/apps/web/security-headers.config.ts +17 -0
  245. package/templates/content/en/BlogList/blog.mdx +4 -71
  246. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  247. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  248. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  249. package/templates/content/en/BookCall/start-project.mdx +1 -17
  250. package/templates/content/en/ContactPage/contact.mdx +6 -63
  251. package/templates/content/en/HomePage/home.mdx +3 -21
  252. package/templates/database/CHANGELOG.md +51 -0
  253. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  254. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  255. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  256. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  257. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  258. package/templates/database/__tests__/seed.test.ts +63 -1
  259. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  260. package/templates/database/auth/schema.prisma +4 -0
  261. package/templates/database/config/schema.prisma +91 -0
  262. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  263. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  264. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  265. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  266. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  267. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  268. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  269. package/templates/database/package.json +1 -1
  270. package/templates/database/seed.ts +57 -2
  271. package/templates/tools/lint-architecture.ts +205 -1
  272. package/versions.json +35 -35
  273. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  274. package/templates/apps/api/src/routes/content/slug.ts +0 -9
  275. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  276. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  277. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  278. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  279. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  280. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  281. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  282. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  283. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  284. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  285. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  286. package/templates/apps/web/lib/checkout.ts +0 -93
  287. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -1,32 +1,37 @@
1
1
  /**
2
- * subscribe_newsletter — add an email to the newsletter list.
2
+ * subscribe_newsletter — add an email to the newsletter list: the body
3
+ * `POST /v1/leads/subscribe` takes.
3
4
  *
4
- * Validates input via Zod, forwards to POST /api/leads/subscribe via
5
- * @working-theory/api-client, and shapes the response into the MCP output schema.
6
- * No business logic — orchestration lives in apps/api B6.
5
+ * The tool adopted the API, arguments and results (#7172 — the operator's
6
+ * decision of 2026-10-19). Its input IS the route's `leadSubscribeBodySchema`
7
+ * (`{ email, source }`; the catalog registers the same shape, so the SDK has
8
+ * already parsed the arguments against it — the `safeParse` here keeps the
9
+ * stdio path and the `invalid_input` branch honest), the parsed body is
10
+ * posted as the agent sent it — no `siteId`, no stamped `source`, and no
11
+ * `firstName`, which never reached the wire — and the route's 201 body
12
+ * `{ id, persisted, email }` comes back unchanged. No spoke marker: a
13
+ * Subscriber row has no payload to carry it. No business logic —
14
+ * orchestration lives in apps/api.
7
15
  *
8
- * Input: { email, firstName? }
9
- * Output: { ok: true, id, persisted } | { ok: false, error: { kind, ... } }
10
16
  * Session/scope: none — anonymous write, no auth required.
11
17
  */
12
18
 
13
19
  import { apiClient } from "@working-theory/api-client";
14
20
  import {
15
- subscribeNewsletterInputSchema,
16
- subscribeNewsletterOutputSchema,
17
- type SubscribeNewsletterInput,
18
- type SubscribeNewsletterOutput,
21
+ leadSubscribeBodySchema,
22
+ type LeadSubscribeBody,
23
+ type LeadWriteOutput,
19
24
  } from "@working-theory/validation";
20
25
 
21
26
  import { visitorForwardHeadersForThisCall } from "../request-context.js";
22
27
 
23
- export { subscribeNewsletterInputSchema, subscribeNewsletterOutputSchema };
24
- export type { SubscribeNewsletterInput, SubscribeNewsletterOutput };
28
+ export { leadSubscribeBodySchema };
29
+ export type { LeadSubscribeBody };
25
30
 
26
31
  export async function ailkSubscribeNewsletter(
27
- input: SubscribeNewsletterInput,
28
- ): Promise<SubscribeNewsletterOutput> {
29
- const parsed = subscribeNewsletterInputSchema.safeParse(input);
32
+ input: LeadSubscribeBody,
33
+ ): Promise<LeadWriteOutput> {
34
+ const parsed = leadSubscribeBodySchema.safeParse(input);
30
35
  if (!parsed.success) {
31
36
  return {
32
37
  ok: false,
@@ -34,24 +39,16 @@ export async function ailkSubscribeNewsletter(
34
39
  };
35
40
  }
36
41
 
37
- const { email } = parsed.data;
38
- // firstName is not part of the canonical subscribeSchema (no payload field);
39
- // it is accepted by the MCP input but dropped here for now. The #7397 spoke
40
- // marker (LEAD_SITE_MARKER_KEY) is absent for the same reason: a Subscriber
41
- // row has no payload column, so subscribe has nowhere to carry it.
42
- const siteId = process.env.AILK_SITE_ID ?? "default";
43
- const body = { siteId, email, source: "mcp" };
44
-
45
42
  // #8073 — forward the address the transport trusted (never an identity
46
43
  // argument on this tool's own schema) so the hub counts the write in that
47
44
  // visitor's own rate-limit bucket rather than this MCP host's.
48
45
  const headers = visitorForwardHeadersForThisCall();
49
46
  const res = headers
50
- ? await apiClient.leads.subscribe(body, { headers })
51
- : await apiClient.leads.subscribe(body);
47
+ ? await apiClient.leads.subscribe(parsed.data, { headers })
48
+ : await apiClient.leads.subscribe(parsed.data);
52
49
  if (!res.ok) {
53
50
  return { ok: false, error: { kind: "api_error", status: res.status } };
54
51
  }
55
52
 
56
- return { ok: true, id: res.body.id, persisted: res.body.persisted };
53
+ return res.body;
57
54
  }
@@ -83,6 +83,21 @@ const WORKSPACE_TOOL_ENTRIES: Record<
83
83
  specifier: "./tools/inbox_submissions.js",
84
84
  exportName: "ailkMarkInboxSubmissionDone",
85
85
  },
86
+ list_listing_applications: {
87
+ specifier: "./tools/inbox_applications.js",
88
+ exportName: "ailkListListingApplications",
89
+ },
90
+ get_listing_application: {
91
+ specifier: "./tools/inbox_applications.js",
92
+ exportName: "ailkGetListingApplication",
93
+ },
94
+ decide_listing_application: {
95
+ specifier: "./tools/inbox_applications.js",
96
+ exportName: "ailkDecideListingApplication",
97
+ },
98
+ // #9137 — the pre-rename names, deprecated aliases of the three above for
99
+ // one release: each export is the same function, so the same arguments and
100
+ // result.
86
101
  list_inbox_applications: {
87
102
  specifier: "./tools/inbox_applications.js",
88
103
  exportName: "ailkListInboxApplications",
@@ -261,37 +261,16 @@ CONTENT_ADAPTER=mdx
261
261
  # BETTER_STACK_HEARTBEAT_URL=
262
262
 
263
263
  # =============================================================================
264
- # Scheduling — the scheduling module's booking page (#4633)
264
+ # Scheduling — the scheduling module's booking pages (#4633, #8843)
265
265
  # =============================================================================
266
- # Which event `/meet` books, as "<owner>/<event-slug>" — the path of the
267
- # provider's own public booking page. Read server-side in
268
- # app/[locale]/meet/page.tsx and handed to the `booker` section, which
269
- # sends it back to apps/api's /v1/schedule/* routes to name the event.
270
- #
271
- # Unset is a supported state: `/meet` 404s, exactly as if the scheduling
272
- # module were not installed.
273
- #
274
- # NOT a credential, and deliberately NOT a NEXT_PUBLIC_ variable — the API key
275
- # it pairs with (CAL_API_KEY) lives in apps/api and this app never reads it, so
276
- # keeping the whole scheduling config surface server-read is what makes that
277
- # boundary checkable rather than merely intended.
278
- # SCHEDULING_CAL_LINK=owner/event-slug
279
- #
280
- # The booker's /v1/schedule/* calls go through this site's same-origin relay
281
- # (see the relay block above, #7644), so the baseline CSP's
282
- # `connect-src 'self'` already covers them — no CSP extension is needed for
283
- # them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
266
+ # No variable here. /meet, /private and /meet/<slug> read every meeting fact
267
+ # — which meetings are Live, public or private, their titles, lengths and
268
+ # Cal.com links — from apps/api's GET /v1/schedule/meetings, which resolves
269
+ # the workspace from this site (the site alias keyless, a spoke by its key)
270
+ # and composes each Cal.com link from that workspace's own account. The
271
+ # one-meeting "<owner>/<event-slug>" variable this block used to set is
272
+ # retired, and nothing reads it.
284
273
  #
285
274
  # On a SPOKE (create-ailk --topology spoke, #7872) this site has no apps/api:
286
- # /meet lists the meeting types the hub stores for this spoke's workspace and
287
- # books through the hub at AILK_API_URL with AILK_SPOKE_KEY. Then:
288
- # - the FIRST segment (the owner) must name the provider account the hub
289
- # uses for this spoke's workspace (its scheduling connection). It composes
290
- # each booker's calLink; it is not an access control — the hub checks a
291
- # spoke booking's slug, never its owner.
292
- # - the SECOND segment is ignored: a workspace with no stored meeting-type
293
- # list shows no meetings, because the hub books only a slug it stores.
294
- # - the booker's two calls count against the relay's per-visitor budget,
295
- # which holds per visitor only behind an edge that overwrites
296
- # x-forwarded-for and x-real-ip (Vercel does); elsewhere every visitor
297
- # shares one budget.
275
+ # the pages read the hub's meetings for this spoke's workspace at AILK_API_URL
276
+ # with AILK_SPOKE_KEY, so a workspace with no stored meeting list shows none.
@@ -143,6 +143,21 @@ the site's contact email, never a form that would report a message as sent
143
143
  when it reached nobody. A failed status read counts as accepting, so the
144
144
  form renders and its submit reports honestly.
145
145
 
146
+ **Every visible fact on the form comes from the magnet (#8847).** The page
147
+ reads the magnet through `lib/in-page-magnet.ts`, the in-page form shape:
148
+ its field step's `copy.title` is the page's H1 and `<title>` and its
149
+ `copy.body` the line under it (`framedSources` writes both into the first
150
+ `contact-form` section, and into the frontmatter title when that section
151
+ leads), and its `ending` is the success message (`{name}` filled from the
152
+ posted name). The section's own `submitLabel` and `successMessage` apply
153
+ only to its own fields. When the hub cannot be read the seeded
154
+ `contactFormFlowConfig` renders, so a fresh deploy looks the same. Under
155
+ the form one muted line reads "Prefer email?" (`contact.preferEmail`) and
156
+ the `_site.mdx` contact email, absent when the site has none. The default
157
+ Contact form's statement is the two-link "I accept the Terms and Privacy
158
+ Policy." (`CONTACT_FORM_TERMS_STATEMENT`); sign-up keeps the three-link
159
+ `ACCOUNT_TERMS_STATEMENT`.
160
+
146
161
  The form's fields come from the workspace's **Contact form magnet** (#8661):
147
162
  every workspace is seeded one, live at slug `contact`, and the page is rendered
148
163
  on every request (`force-dynamic`) and reads it through the public flow read,
@@ -194,7 +209,7 @@ redirect, never into a code, cookie or continuation ref (a repeated
194
209
  `login_hint` is `400 invalid_request`). `email` and `login_hint` are in the
195
210
  telemetry scrub lists (`lib/telemetry-scrub.ts`, `packages/analytics`).
196
211
 
197
- **The same link on the magnet walk and /careers (#8771).** The `/careers/<slug>` job page's action reads the API's `next` the same way (`apply.actions.ts`): `invite` returns the link and `contact.trackInAccount`, anything else — or a throw after the write — exactly `{ kind: "success" }`, so an error never invites a duplicate application. The walk (`/flows/<slug>`, `FlowStepperClient`) tracks the first `mapsTo: "email"` answer it submitted; on a completing response with `next: { completion: "invite" }` it asks the server action `accountInviteLinkAction` (the builder reads the generated topology, so it never runs in the client) and shows the link inside `FlowStepper`'s finished card (its `completedAction` slot), or — for a flow with a `thankYou` block — writes one `sessionStorage` item, `ailk:account-invite`, that `/thanks/<slug>`'s `AccountInviteLink` reads once and removes (the page passes it to `renderPage` as `trailing`, so it sits inside `<main>`). Each link sits in a `data-sentry-block` element (Session Replay records `href`s); no walk URL ever carries the email.
212
+ **The same link on the magnet walk and /careers (#8771).** The careers apply page's action (`/careers/<slug>/apply` on a site without the walk, #8849) reads the API's `next` the same way (`apply.actions.ts`): `invite` returns the link and `contact.trackInAccount`, anything else — or a throw after the write — exactly `{ kind: "success" }`, so an error never invites a duplicate application. The walk (`/flows/<slug>`, `FlowStepperClient`) tracks the first `mapsTo: "email"` answer it submitted; on a completing response with `next: { completion: "invite" }` it asks the server action `accountInviteLinkAction` (the builder reads the generated topology, so it never runs in the client) and shows the link inside `FlowStepper`'s finished card (its `completedAction` slot), or — for a flow with a `thankYou` block — writes one `sessionStorage` item, `ailk:account-invite`, that `/thanks/<slug>`'s `AccountInviteLink` reads once and removes (the page passes it to `renderPage` as `trailing`, so it sits inside `<main>`). Each link sits in a `data-sentry-block` element (Session Replay records `href`s); no walk URL ever carries the email.
198
213
 
199
214
  A `contact-form` section in the `column` variant (the minimalism design's
200
215
  Contact page) renders one centred heading, one line and the form, and the
@@ -214,51 +229,296 @@ and on a declined terms turn, Skip or a failed submit swaps to
214
229
  embedding for it is behind the hero CTA's lightbox gate
215
230
  (`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
216
231
 
232
+ ## Newsletter, unsubscribe and the footer signup
233
+
234
+ The workspace's **Newsletter magnet** (#8847) — seeded live at slug
235
+ `newsletter` in every workspace — is the only source of `/newsletter`,
236
+ `/unsubscribe` and the footer signup; none of their copy is in page code,
237
+ content or the message catalog. All three read it through
238
+ `lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
239
+ seconds per instance like /contact's read.
240
+
241
+ - **`/newsletter`** renders on every request. The magnet's step title is the
242
+ H1 and `<title>`, its body the line under it, and the standard
243
+ `NewsletterFormInline` (visible Email label, the consent row above the
244
+ field, the consent field's help line, Subscribe, and the magnet's ending in
245
+ place of the form) goes into the page's first `contact-form` section. With
246
+ the hub unreadable the seeded `newsletterFlowConfig` renders; with capture
247
+ off (`GET /v1/leads/capture`'s `newsletter`) the slot is `ContactFallback`
248
+ with the `_site.mdx` email. A page with no `contact-form` section keeps its
249
+ pre-#8847 composition.
250
+ - **The signup.** `subscribeNewsletterAction` refuses a post without
251
+ `consent=true`, then re-reads the magnet and submits its step through
252
+ `flows.submitStep` — the step key, the email field and the consent key
253
+ (`terms.newsletter`) all from the server's read, never the post — so the
254
+ hub writes the lead, the `newsletter` consent row and the welcome email.
255
+ Without a flow route (a Free build) it posts `leads.subscribe`, as before.
256
+ It lives in `lib/newsletter.actions.ts`, not under `app/[locale]/newsletter/`,
257
+ because the footer imports it and a scaffold with the `newsletter` surface
258
+ off drops that route directory.
259
+ - **`/unsubscribe`** takes its title, line, button and done message from the
260
+ magnet's `unsubscribe` block (strings only across the island edge); the
261
+ already, invalid and unavailable lines stay in the `unsubscribe` catalog.
262
+ - **The footer** fills `Footer`'s `subscribe` slot with the compact form
263
+ (stacked, `sr-only` label) only when the magnet is served and capture is
264
+ on; the built-in config never fills it.
265
+
266
+ ## Careers
267
+
268
+ Three linked pages, each built from the `content/<locale>/JobPosting/*.mdx`
269
+ records (#8849):
270
+
271
+ - **`/careers`** renders in sections mode, the /alternatives pattern: one
272
+ generated `items` section first, a card per posting (newest `publishedAt`
273
+ first), then the seed's declared sections, so the card grid and the
274
+ `CollectionPage`'s `ItemList` (`ListItem`s with `position` and `url`) agree.
275
+ The section's `filter` gives a search box and employment-type and location
276
+ selects; the facets read the postings' first-class `employmentType` and
277
+ `location` frontmatter, never `schema.mainEntity`. Every posting stays in the
278
+ server HTML. Every visible string is a `careers` message.
279
+ - **`/careers/<slug>`** keeps its chrome and ends with an Apply button
280
+ (`job-apply-cta`, `Button` size `big`) to `/careers/<slug>/apply`; its
281
+ `ApplyAction` targets the same URL. It makes no hub read.
282
+ - **`/careers/<slug>/apply`** is the `signal-collectors` walk where the module
283
+ is present (§ Magnet pages), else the Free apply page,
284
+ `careers/[slug]/[action]/page.tsx`: the posting's title over the one-step
285
+ `LeadCaptureForm` bound to `apply.actions.ts` with the slug as `job`,
286
+ `noindex, follow` and full-screen. Next.js serves the static `apply`
287
+ segment first, the `[slug]/[detail]` idiom; every other action 404s. Lead
288
+ capture is never-gate, so every tier takes an application. Both apply pages
289
+ read the posting's title through `lib/posting-title.ts`.
290
+
291
+ ## Meeting pages
292
+
293
+ The scheduling module's booking pages (#8843) read every meeting fact from
294
+ the workspace — no content file, env var or deploy sits between a Meetings or
295
+ Cal.com edit and the page.
296
+
297
+ - **One builder.** `lib/meetings.tsx` reads `GET /v1/schedule/meetings`
298
+ through `schedule.meetings()`, re-parses the body with `MeetingListSchema`
299
+ and shares the result per server instance for 15 seconds (concurrent and
300
+ failed reads too, the contact page's #8731 shape). Any failure is
301
+ `{ ok: false }`. It builds the index cards (`meetingCardsSection`: `items`
302
+ tiles, two columns, `gapAbove: "tight"`, one card per bookable meeting with
303
+ the hub's title and description and a "Book <n> min" button) and the
304
+ meeting page's booker (`meetingPageSections`: `scheduler-embed`, provider
305
+ `cal`, month view). Every visible string is a `schedule` message.
306
+ - **`/meet` and `/private`** both return `MeetingIndex`
307
+ (`app/[locale]/meet/meeting-index.tsx` — beside the route, because it
308
+ renders the paid `BookingProfileHeader`, which `architecture.yaml` admits
309
+ only there): the back link alone (`Nav` `bar="bare"` `bareMark={false}`),
310
+ the header's avatar and one H1, then the cards of that visibility. One
311
+ meeting redirects to `/meet/<slug>`; none, or a failed read, shows the
312
+ site's contact email — never a 404 or a 500. `/private` is
313
+ `noindex, nofollow`, has no seed and no nav entry: private means unlisted,
314
+ not access-controlled.
315
+ - **`/meet/<slug>`** is one centred H1 ("Schedule a <title> meeting"),
316
+ Cal.com's inline embed from the hub's Cal.com link, and "← All meeting
317
+ types". A Live meeting that cannot be booked, or a failed read for a valid
318
+ slug, shows the contact email; an unknown slug or a dotted one is a 404. A
319
+ `BookCall/<slug>.mdx` seed gives only the URL, the JSON-LD inputs and the
320
+ fallback title — its sections are never rendered here.
321
+ - **Chrome.** `lib/modules.generated.ts`'s `SCHEDULING_PAGES` (written by
322
+ create-ailk from `apps/web/package.json`, never copied by `update`) and
323
+ `lib/auth-routes.ts`'s `isBookingPath` make the locale layout drop the
324
+ marketing shell on `/meet`, `/meet/<slug>` and `/private`; `/meet/confirmed`
325
+ and a site without the module keep it. The consent banner stays.
326
+ - **The CSP (D12).** `security-headers.config.ts` exports `SCHEDULING_CSP`
327
+ (Cal.com's app origin in `script-src` and `connect-src`; `frame-src`
328
+ `'self'` and the same origin) and a frozen `SCHEDULING_SECURITY_HEADERS`.
329
+ `middleware.ts` applies it instead of `DEFAULT_SECURITY_HEADERS` only when
330
+ `SCHEDULING_PAGES && isBookingPath(pathname, enabledLocales)`; sign-in,
331
+ account, checkout, `/meet/confirmed` and every site without the module keep
332
+ the default policy. `architecture.yaml`'s `cal\.com` rule excludes the
333
+ config, the middleware and their two tests for this reason.
334
+
335
+ ## Magnet pages
336
+
337
+ Every multi-step magnet is a page pair (#8840), rendered by one module,
338
+ `app/[locale]/flows/[slug]/magnet-page.tsx`:
339
+
340
+ - **`/<magnet>`** — the indexable landing, inside the site's Nav and Footer: a
341
+ centred hero of the flow record's `landing` copy (title, line, muted meta
342
+ line, button to `/<magnet>/start`; absent keys fall back to step 1's title
343
+ and the `flow` messages) and the `Assessment` page type's `WebApplication`
344
+ JSON-LD.
345
+ - **`/<magnet>/start`** — the walk, `noindex, follow`, full-screen with the
346
+ back arrow alone (`NavBackLink`) above step 1. `lib/magnet-routes.ts`'s
347
+ `isMagnetWalkPath` is what the locale layout drops the Nav and Footer on:
348
+ any path ending in `start`, plus `/careers/apply`,
349
+ `/careers/<posting>/apply` and `/trial/request`. The cookie consent banner
350
+ stays.
351
+ - **The ending.** A flow with an `ending` ends on it, never on `thankYou`:
352
+ `FlowStepperClient` picks the outcome from the walk's answers
353
+ (`selectFlowEndingOutcome`) — a screen in the finished card with `{name}`
354
+ filled, or a redirect to a site path — and re-checks every destination
355
+ against `flowSitePathSchema` before it renders or navigates.
356
+ - **The hub is down.** Both pages read the flow through one `flows.get`,
357
+ shared per instance for 15 seconds per slug; a refused connection or a 5xx
358
+ shows `ContactFallback` with the site's contact email (both pages
359
+ `noindex`), and a 404 is `notFound()`. `/careers/apply` exists apart from
360
+ its flow, so it passes `renderMagnetStart` a `title`: a 404, a refused
361
+ connection or a 5xx all render the contact email under that `<h1>`
362
+ (#9219).
363
+ - **The pairs.** `/flows/<slug>` is the canonical pair. `/careers/apply` and
364
+ `/careers/<posting>/apply` walk the Job application magnet (the second
365
+ titles step 1 with the posting and sends its slug as the hidden `job`).
366
+ `/trial` and `/trial/request` are the trial magnet's pair (#9287): the
367
+ landing passes `renderMagnetLanding` a `startPath`, so its one button
368
+ (the magnet's `landing.cta`, "Request a free trial") goes to
369
+ `/trial/request` rather than `/trial/start`, and the walk passes a
370
+ `title`, so an absent flow renders the contact email under its own `<h1>`,
371
+ as `/careers/apply` does. Both are `signal-collectors` slice files.
372
+ Any other pair is written by `create-ailk add-route magnet --path <path>
373
+ --flow <slug>`: two short pages carrying the `Standard magnet page
374
+ (#8840)` marker, which `lib/__tests__/structured-data.test.tsx` exempts
375
+ from its `renderPage` check. A site adds a magnet by binding a pair to a
376
+ slug, never by copying stepper code.
377
+ - **The recommender's landing (#9071).** A recommending flow with an offer
378
+ carrying `lookupKey` (an Offerings recommender, such as the standard
379
+ `recommender` magnet the Admin's Offerings page opens) never renders its
380
+ results page: the results permalink
381
+ (`flows/[slug]/results/[payload]/page.tsx`) redirects a completed walk to
382
+ `/<locale>/pricing?plan=<plan>` while the first fit's lookup key is in the
383
+ current shown set, else to `/<locale>/pricing` with no badge (no fit, a key
384
+ no longer shown, any failed read, or no answer within 5 seconds). The
385
+ shown-set read is `offerings.shown()`, `/pricing`'s own, shared per
386
+ instance for 15 seconds; `?plan=` carries the key's `{plan}` part, the only
387
+ value `/pricing` reads. A flow with no such offer, or whose first fit
388
+ carries none, renders the results page as before, with no read.
389
+ - **The deliverable pages (#8848).** A flow whose served config carries a
390
+ `deliverable` block (a `file`, `video`, `page` or `external` payload, gated
391
+ or not) has one route, `/reports/<slug>`, rendered by
392
+ `app/[locale]/reports/deliverable-page.tsx`; both halves of every other pair
393
+ redirect a deliverable flow there. Gated: the page is a centred hero of the
394
+ block's copy with the back arrow to `/reports` and a button to the walk at
395
+ `/reports/<slug>/start`, and the walk's finished card shows the deliverable
396
+ after the ending screen — the reader link `/reports/<slug>?g=<grant>` (in a
397
+ `data-sentry-block` element), an inline player, a site link or a link out;
398
+ a redirect ending never runs when the response carries one, and a file walk
399
+ that minted no grant says the link is in the email. Ungated: the reader over
400
+ the site's own `/reports/<slug>/file` route handler (it reads the hub's
401
+ `GET /v1/deliverables/open/:slug` with the spoke key and the visitor
402
+ forward, 30 reads per visitor per 10 minutes — skipped when no client
403
+ address is readable — the five file headers passed through), a player, a redirect to the site page, or a button out. Every
404
+ page under `/reports/<slug>` is chrome-free (`isMagnetWalkPath`); `/reports`
405
+ keeps its chrome. The reader's Share is built from the page URL, never the
406
+ bytes or `?g=`. A hub read that fails falls back to the slug's `ReportPage`
407
+ content. With none, #8848 D6 holds for a configured hub: a 5xx or a refused
408
+ connection shows the contact email (`noindex`). A site with no hub to ask —
409
+ the read answers 503 `flows_not_configured`, or no API origin
410
+ (`AILK_API_URL`, `NEXT_PUBLIC_AILK_API_URL`, `NEXT_PUBLIC_API_URL`) is set —
411
+ reads `not-configured` (set once in `readMagnet`), and the slug 404s, as
412
+ does its `/start` (#9219). `/downloads` and `/downloads/<slug>` are
413
+ permanent redirects into `/reports` (signal-collectors files).
414
+
415
+ ## Pricing page
416
+
417
+ The pricing page (`apps/web/app/[locale]/pricing/page.tsx`) renders the
418
+ workspace's Offerings shown set from Stripe (#8927). Before rendering it
419
+ reads `GET /v1/content/shown-offerings` through `offerings.shown()` from
420
+ `@working-theory/api-client`, renders on every request (`force-dynamic`),
421
+ and shares the read across requests for 15 seconds per server instance, the
422
+ contact page's window and reason: a product switched off disappears within
423
+ 15 seconds, with no deploy. The API resolves the workspace (a spoke by its
424
+ key), so the page names none. It renders one of three pages:
425
+
426
+ - **Unconnected** — the route answers `unconnected`, or the deployment runs
427
+ in production with none of the API origin variables `getApiBase()` reads:
428
+ today's MDX page through `renderPage`, byte for byte, `?plan=` matching
429
+ authored plan keys as #8842 made it.
430
+ - **A current shown set** — `lib/offerings-pricing.ts` maps it to a plans
431
+ fragment and an add-ons fragment. The first `pricing` section that is not
432
+ `addons` becomes the Stripe plans, and the first `addons` one the Stripe
433
+ add-ons, each keeping only its authored section copy (eyebrow, headline,
434
+ description and layout knobs), never an authored plan or price. Every other
435
+ `pricing` section is removed; a fragment with no section to replace goes
436
+ after a leading hero. `?plan=` moves the recommendation only for a
437
+ `{plan}`-grammar value (`LOOKUP_KEY_PLAN_PATTERN`), so `?plan=Growth` keeps
438
+ the workspace's own. Copy comes from the `pricingOfferings` namespace.
439
+ - **Anything else** — an expired or empty shown set, `empty`, a non-2xx, an
440
+ unknown body or a throw: every `pricing` section gives way to one
441
+ `contact-form` section holding `ContactFallback` with the site's contact
442
+ email (`readSiteContactEmail()`), in the `contact` namespace's copy. Never
443
+ an error page, and no `Offer` is published.
444
+
445
+ Stripe text reaches the page only as React text and JSON-LD only through
446
+ `renderPage`'s serializer; the title and meta stay the MDX page's.
447
+
448
+ Every Stripe card links `/api/offerings/checkout/<lookup_key>`, which
449
+ `app/api/offerings/checkout/[lookupKey]/route.ts` serves (#9051). Only a
450
+ buyer's page load starts anything: a request that is not a top-level
451
+ navigation (`Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest: document`), a
452
+ `HEAD`, a prefetch, an RSC fetch or a cross-site load is refused before any
453
+ call. A key `parseLookupKey` refuses, a site without the `commerce` claim, and
454
+ production with no API origin are refused locally too. The route then reads
455
+ the shown set, shared per instance for 15 seconds like the page's own read,
456
+ and goes on only when the site is connected and that set shows the key, so an
457
+ unconnected site never reaches the API's env path. Each visitor address gets 5
458
+ starts per 10 minutes per instance. The route POSTs `/v1/checkout/sessions`
459
+ through `checkout.createSession` with the visitor's `x-forwarded-for`, success
460
+ URL `/purchase/success?session_id={CHECKOUT_SESSION_ID}` and cancel URL
461
+ `/pricing`, both on the origin of `resolveBaseUrl()` and never the request's
462
+ host, and answers a `303` to the `https:` Checkout URL the API returns. Every
463
+ refusal is a `303` to `/pricing` with no body, `Cache-Control: no-store` and
464
+ one `[offerings-checkout] refused reason=<reason>` warning; no response or
465
+ log line carries the key. It sends `OSS_API_INTERNAL_SECRET` as
466
+ `x-internal-auth` only from a site that is not a spoke and only to an
467
+ `https:` or loopback API, so the hub's own `apps/web` must hold the API's
468
+ value when the API sets it. A spoke never holds that secret: it sells only
469
+ when its hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS` and does not
470
+ set `OSS_API_INTERNAL_SECRET`, and otherwise every spoke buy returns to
471
+ `/pricing`.
472
+
473
+ An existing scaffold adopts the new page by bringing
474
+ `lib/offerings-pricing.ts`, `app/api/offerings/checkout/[lookupKey]/route.ts`
475
+ (it sits under `app/api/`, outside the route registry `sync-routes` copies),
476
+ the `pricingOfferings` namespace and an
477
+ `@working-theory/api-client` release that exports `offerings` with it, and
478
+ needs an API (its own `apps/api`, or a spoke's hub) that serves
479
+ `GET /v1/content/shown-offerings` (#8924): an older API answers that path
480
+ non-2xx, which renders the contact email in place of the authored pricing.
481
+
217
482
  ## Spoke checkout
218
483
 
219
- A spoke that carries `commerce` sells through the standard spoke checkout
220
- (#8748), six files every such spoke shares and `create-ailk update` keeps
221
- current. A hub, or a spoke without `commerce`, never gets them: the hub's
222
- checkout is `apps/api`'s.
223
-
224
- - **The files.** `app/api/checkout/[offering]/route.ts` (the buy route),
225
- `app/api/webhooks/stripe/route.ts`, `app/[locale]/checkout/thank-you/page.tsx`,
226
- `app/[locale]/checkout/unavailable/page.tsx`, `lib/checkout.ts` (the
227
- standard) and `lib/checkout-config.ts` (the site's data). Every guarantee —
228
- live-key admission, the buy guards, the webhook's verification and filters,
229
- the duplicate guard — lives in `@working-theory/spoke-checkout` and arrives by
230
- a package bump.
231
- - **One data file.** `lib/checkout-config.ts` is the one file a site edits:
232
- the `const SITE` stamp and the `offerings` map, no import and no code.
233
- `create-ailk update` never rewrites it. Name an offering's slug after the last
234
- segment of the Service or Product page that sells it.
235
- - **A slug that is not an offering answers 404**, before any guard, limiter,
236
- admission or buyer lookup. Every other refusal is a 303 to
237
- `/checkout/unavailable`.
238
- - **Spoke only.** Both routes answer 404 and both pages call `notFound()`
239
- unless `lib/topology.generated.ts`'s `TOPOLOGY` is `"spoke"`, so this
240
- monorepo's hub-shaped `apps/web` serves none of them.
241
- - **One sender, two importers.** `lib/checkout.ts`'s private fulfilment is the
242
- one `apps/web` file that sends an `Idempotency-Key`: one `leads.submit` per
243
- paid session, `payload.site` set to the checkout stamp (never
244
- `AILK_LEAD_SITE`), the session id as the key, and a body that must stay
245
- byte-identical between attempts, because the hub replays only an identical
246
- body. The payload's last key, `termsAccepted`, is the session's `ailk_terms`
247
- stamp when the buyer accepted Stripe's terms box, else `null`; the hub writes
248
- the buyer's checkout consent grant from it (#8778). The hub leaves that key
249
- out of its replay comparison, so a redelivery across the update still
250
- replays. Only the two route files may import `lib/checkout.ts` or the package
251
- (`tests/architecture/idempotency-key-producers.test.ts`, `architecture.yaml`);
252
- a spoke that signs in through its hub adds a third, its rendered billing
253
- action, which its own `architecture.yaml` admits. The thank-you page may
254
- import the package for `readPaidCheckoutEmail` alone, never
255
- `lib/checkout.ts` (#8757 D13): it reads `session_id`, and for a paid
484
+ **A spoke sells through the Offerings buy route, and only through it.**
485
+ `app/api/offerings/checkout/[lookupKey]/route.ts` (§ Pricing page) is the one
486
+ selling path on every site: the seller's Stripe account is held once per
487
+ Organization on the hub, and the hub authorizes each buy against the
488
+ workspace's shown set. The spoke's legacy env-key checkout — its own buy route
489
+ (`app/api/checkout/[offering]/route.ts`), its Stripe webhook route
490
+ (`app/api/webhooks/stripe/route.ts`), `lib/checkout.ts` (the fulfilment) and
491
+ the generated checkout e2e workflow — is retired (#9142). `create-ailk
492
+ sync-routes --apply` removes them from an existing spoke when they are still
493
+ the copies create-ailk wrote, and names any it leaves.
494
+
495
+ What a spoke that carries `commerce` still gets (#8748), kept current by
496
+ `create-ailk update`; a hub, or a spoke without `commerce`, never gets them:
497
+
498
+ - **The two return pages.** `app/[locale]/checkout/thank-you/page.tsx` and
499
+ `app/[locale]/checkout/unavailable/page.tsx`. Both call `notFound()` unless
500
+ `lib/topology.generated.ts`'s `TOPOLOGY` is `"spoke"`, so this monorepo's
501
+ hub-shaped `apps/web` serves neither.
502
+ - **One data file.** `lib/checkout-config.ts` is the site's stamp,
503
+ `checkoutConfig.site`, and nothing else: no import and no code.
504
+ `create-ailk update` never rewrites it.
505
+ - **One package reader.** The thank-you page may import
506
+ `@working-theory/spoke-checkout` for `readPaidCheckoutEmail` alone
507
+ (`architecture.yaml`, #8757 D13): it reads `session_id`, and for a paid
256
508
  session of this site under 24 hours old shows "Open your account" with the
257
509
  buyer's email (`accountInviteHref`), else the same link with no email. Each
258
510
  visitor gets 10 reads per 10 minutes (`src/lib/rate-limit.ts`); the page
259
- sends no referrer, renders dynamically and never touches a cookie.
260
- `spokeCheckoutConfig` reads only `site` and `offerings` from the site's file,
261
- so admission always reads `process.env`.
511
+ sends no referrer, renders dynamically and never touches a cookie. The read
512
+ admits the spoke's `STRIPE_SECRET_KEY` from `process.env`; that key never
513
+ sells.
514
+ - **The billing action (a spoke that signs in through its hub).** create-ailk
515
+ renders `app/[locale]/(authed)/account/billing/spoke-billing.action.ts`,
516
+ which builds the package's billing reads from `checkoutConfig.site` and
517
+ `resolveBaseUrl()`; the spoke's own `architecture.yaml` admits it as the
518
+ package's other importer.
519
+ - **No `Idempotency-Key`.** No `apps/web` file sends one
520
+ (`tests/architecture/idempotency-key-producers.test.ts`): the fulfilment that
521
+ did is retired with the legacy checkout.
262
522
 
263
523
  ## The same-origin relay (`/v1/*`)
264
524
 
@@ -286,7 +546,29 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
286
546
  - Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
287
547
  - Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
288
548
 
289
- Last updated: 2026-10-16 — § Spoke checkout: the purchase lead's `termsAccepted` key, the stamp of the terms the buyer accepted, from which the hub writes the checkout consent grant (#8778) — verified against `lib/checkout.ts` and `packages/spoke-checkout/src/webhook.ts`.
549
+ Last updated: 2026-10-20 — § Magnet pages: the trial magnet's pair, `/trial` (its button to `/trial/request` through `renderMagnetLanding`'s `startPath`) and the chrome-free `/trial/request` walk with its own `<h1>` fallback (#9287 C2, DD10) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/trial/{page,request/page}.tsx` and `lib/magnet-routes.ts`.
550
+
551
+ Prior: 2026-10-19 — § Magnet pages: `/careers/apply` renders the contact email under its own `<h1>` when its flow is absent or the hub cannot answer; `readMagnet`'s `not-configured` kind 404s an unknown `/reports/<slug>` (and its `/start`) on a site with no hub to ask, while a configured hub that is down keeps #8848 D6's contact email (#9219) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/careers/apply/page.tsx`, `app/[locale]/reports/deliverable-page.tsx` and `app/[locale]/reports/[slug]/{page,start/page}.tsx`.
552
+
553
+ Prior: 2026-10-18 — § Spoke checkout: the Offerings buy route is the one selling path, and the legacy env-key buy route, webhook route, `lib/checkout.ts` and checkout e2e workflow are retired (#9142) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts`, `app/[locale]/checkout/*/page.tsx`, `lib/checkout-config.ts` and create-ailk's `spoke-checkout.ts` and `sync-routes.ts`.
554
+
555
+ Prior: 2026-10-18 — § Magnet pages: the recommender's landing on `/pricing?plan=` (#9071) — verified against `app/[locale]/flows/[slug]/results/[payload]/page.tsx` and its `landing.test.tsx`.
556
+
557
+ Prior: 2026-10-18 — § Magnet pages: the deliverable pages at `/reports/<slug>`, their ending, the file proxy and the `/downloads` redirects (#8848) — verified against `app/[locale]/reports/{deliverable-page.tsx,[slug]/page.tsx,[slug]/start/page.tsx,[slug]/file/route.ts}`, `app/[locale]/flows/[slug]/{magnet-page,FlowStepperClient}.tsx`, `app/[locale]/downloads/` and `lib/magnet-routes.ts`.
558
+
559
+ Prior: 2026-10-18 — § Contact page and § Newsletter, unsubscribe and the footer signup: the in-page form shape — every visible fact from the Contact form and Newsletter magnets (#8847) — verified against `lib/in-page-magnet.ts`, `app/[locale]/{contact,newsletter,unsubscribe}/page.tsx`, `lib/newsletter.actions.ts` and `app/[locale]/layout.tsx`.
560
+
561
+ Prior: 2026-10-18 — § Careers: the searchable /careers, the posting's Apply button and the Free apply page (#8849) — verified against `app/[locale]/careers/{page,[slug]/page,[slug]/[action]/page}.tsx` and `lib/posting-title.ts`; § Pricing page: the Offerings buy route `app/api/offerings/checkout/[lookupKey]/route.ts` starts the hosted Checkout for a shown key and returns every refusal to `/pricing`, and the spoke precondition (#9051) — verified against the route, its suite and `lib/offerings-pricing.ts`.
562
+
563
+ Prior: 2026-10-18 — § Meeting pages: the booking pages carry the Cal.com CSP (`SCHEDULING_CSP`, applied by `middleware.ts` on booking paths only) and `scheduler-embed` forwards `gapAbove` (#8843 D12, D13) — verified against `security-headers.config.ts`, `middleware.ts` and `packages/ui/src/sections/SchedulerEmbed.tsx`.
564
+
565
+ Prior: 2026-10-18 — § Meeting pages: the booking pages read the workspace's meetings through one builder, the chrome-free booking branch and its module flag (#8843) — verified against `lib/meetings.tsx`, `app/[locale]/meet/`, `app/[locale]/private/page.tsx`, `lib/auth-routes.ts` and `app/[locale]/layout.tsx`.
566
+
567
+ Prior: 2026-10-18 — § Magnet pages: the standard magnet page pair, its walk predicate, ending, hub-down fallback and `add-route magnet` (#8840) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `FlowStepperClient.tsx`, `lib/magnet-routes.ts`, `app/[locale]/layout.tsx` and `app/[locale]/careers/{apply,[slug]/apply}/page.tsx`.
568
+
569
+ Prior: 2026-10-18 — § Pricing page: `/pricing` renders the Offerings shown set, the contact email when there is none to sell, and today's page for an unconnected site (#8927) — verified against `app/[locale]/pricing/page.tsx` and `lib/offerings-pricing.ts`.
570
+
571
+ Prior: 2026-10-16 — § Spoke checkout: the purchase lead's `termsAccepted` key, the stamp of the terms the buyer accepted, from which the hub writes the checkout consent grant (#8778) — verified against `lib/checkout.ts` and `packages/spoke-checkout/src/webhook.ts`.
290
572
 
291
573
  Prior: 2026-10-16 — § Spoke checkout: the thank-you page reads the paid session's email for "Open your account" (#8757 D13, #8800) — verified against `app/[locale]/checkout/thank-you/page.tsx` and `packages/spoke-checkout/src/paid-email.ts`.
292
574
 
@@ -4,7 +4,7 @@
4
4
  * account, and its Server Action `acceptAccountAgreementsAction`.
5
5
  */
6
6
  import { consentStatementText } from "@working-theory/ui/blocks";
7
- import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
7
+ import { ACCOUNT_TERMS_STATEMENT } from "@working-theory/validation";
8
8
  import {
9
9
  fireEvent,
10
10
  render,
@@ -56,7 +56,7 @@ const LABELS = {
56
56
 
57
57
  const AGREEMENT: AgreementsInterstitialAgreement = {
58
58
  id: "account_terms",
59
- statement: CONTACT_FORM_TERMS_STATEMENT,
59
+ statement: ACCOUNT_TERMS_STATEMENT,
60
60
  acceptedVersion: null,
61
61
  };
62
62
 
@@ -132,7 +132,7 @@ describe("AgreementsInterstitial — the statement renders with its links (AC-5,
132
132
  "account-agreements-statement-account_terms",
133
133
  );
134
134
  expect(statement.textContent).toBe(
135
- consentStatementText(CONTACT_FORM_TERMS_STATEMENT),
135
+ consentStatementText(ACCOUNT_TERMS_STATEMENT),
136
136
  );
137
137
  expect(statement.textContent).not.toContain("[");
138
138
  expect(statement.textContent).not.toContain("](");
@@ -123,7 +123,7 @@ jest.mock("next/navigation", () => ({
123
123
 
124
124
  import { usePhoneTab } from "@working-theory/templates/account";
125
125
  import { consentStatementText } from "@working-theory/ui/blocks";
126
- import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
126
+ import { ACCOUNT_TERMS_STATEMENT } from "@working-theory/validation";
127
127
 
128
128
  import AccountLayout from "../layout";
129
129
  import InboxPage from "../page";
@@ -152,7 +152,7 @@ function agreementsAnswer(entry: { acceptedVersion: number | null; current: bool
152
152
  agreements: [
153
153
  {
154
154
  id: "account_terms",
155
- statement: CONTACT_FORM_TERMS_STATEMENT,
155
+ statement: ACCOUNT_TERMS_STATEMENT,
156
156
  policyVersion: 2,
157
157
  acceptedVersion: entry.acceptedVersion,
158
158
  acceptedAt: entry.acceptedVersion === null ? null : "2026-09-01T00:00:00.000Z",
@@ -694,7 +694,7 @@ describe("the agreements interstitial (#8765 D7)", () => {
694
694
  await renderLayout();
695
695
  expectInterstitialAlone("Please accept our terms");
696
696
  const statement = screen.getByTestId("account-agreements-statement-account_terms");
697
- expect(statement.textContent).toBe(consentStatementText(CONTACT_FORM_TERMS_STATEMENT));
697
+ expect(statement.textContent).toBe(consentStatementText(ACCOUNT_TERMS_STATEMENT));
698
698
  expect(errorSpy).toHaveBeenCalledWith(
699
699
  expect.any(String),
700
700
  { event: "account-agreements", outcome: "read_failed" },