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
@@ -28,50 +28,36 @@
28
28
  * composed from the deployment's `_site.mdx` identity (identity node, optional
29
29
  * secondary, WebSite) rather than the page title. Every other type's body is
30
30
  * the page's own JSON-LD, spread at the root, as before.
31
+ *
32
+ * #7169: the document is built by `readContentDocument` in
33
+ * `@working-theory/content-adapters` — the shared seam the `read_content` MCP
34
+ * tool answers through, parsing the same `@working-theory/validation` exports —
35
+ * over the `_site.mdx` this route still reads (the package may not import
36
+ * `@working-theory/site-config`).
31
37
  */
32
38
  import { readFileSync } from "node:fs";
33
39
  import { join } from "node:path";
34
40
 
35
41
  import {
36
- SCHEMA_ORG_TYPES,
42
+ readContentDocument,
37
43
  type ContentAdapter,
38
- type ContentPage,
39
44
  type MdxContentAdapter,
40
- type MdxPageSource,
41
- type SchemaOrgType,
42
45
  } from "@working-theory/content-adapters";
43
- import {
44
- buildJsonLd,
45
- composeGraph,
46
- graphNodes,
47
- type ContentPageInput,
48
- type JsonLdObject,
49
- type SitewideIdentityInput,
50
- } from "@working-theory/schema";
51
46
  import {
52
47
  SiteConfigSchema,
53
48
  type SiteConfig,
54
49
  } from "@working-theory/site-config/schema";
50
+ import {
51
+ contentPageParamsSchema,
52
+ contentPageQuerySchema,
53
+ } from "@working-theory/validation";
55
54
  import type { FastifyPluginAsync } from "fastify";
56
55
  import { parse as parseYaml } from "yaml";
57
- import { z } from "zod";
58
56
 
59
57
  import { getContentRoot } from "../../lib/content-adapter.js";
60
58
  import { resolveLocale } from "../../lib/locales.js";
61
59
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
62
60
 
63
- import { SLUG_PATTERN } from "./slug.js";
64
-
65
- const paramsSchema = z.object({
66
- type: z.enum(SCHEMA_ORG_TYPES),
67
- slug: z.string().regex(SLUG_PATTERN),
68
- });
69
-
70
- const querySchema = z.object({
71
- locale: z.string().optional(),
72
- include: z.enum(["body"]).optional(),
73
- });
74
-
75
61
  /**
76
62
  * The raw-body capability, probed by the exact name the MDX adapter declares
77
63
  * and the database adapter borrows (#5874) — the same idiom, and the same
@@ -114,38 +100,6 @@ function readSiteConfig(contentRoot: string): SiteConfig | undefined {
114
100
  return siteConfigByRoot.get(contentRoot) ?? undefined;
115
101
  }
116
102
 
117
- /**
118
- * The `HomePage` body's `@graph` (#6597, spec D11): the spine plus the site's
119
- * sitewide identity nodes. The WebSite's `inLanguage` is the spine's own, so
120
- * both carry one value; the Organization falls back to the page's `image` as
121
- * its `logo` when the identity declares none, as the title-derived node did.
122
- */
123
- function homePageGraph(
124
- primary: JsonLdObject,
125
- image: string | undefined,
126
- ): { "@context": "https://schema.org"; "@graph": JsonLdObject[] } {
127
- const config = readSiteConfig(getContentRoot());
128
- let site: SitewideIdentityInput | undefined;
129
- if (config !== undefined) {
130
- const identity = config.siteIdentity;
131
- site = {
132
- origin: config.baseUrl.replace(/\/+$/, ""),
133
- siteName: config.siteName,
134
- baseUrl: config.baseUrl,
135
- inLanguage:
136
- typeof primary["inLanguage"] === "string" ? primary["inLanguage"] : undefined,
137
- identity:
138
- identity.kind !== "Person" && identity.logo === undefined && image !== undefined
139
- ? { ...identity, logo: image }
140
- : identity,
141
- };
142
- }
143
- return {
144
- "@context": "https://schema.org",
145
- "@graph": graphNodes(composeGraph({ primary, site, isHomePage: true })),
146
- };
147
- }
148
-
149
103
  export const getPageRoute: FastifyPluginAsync = async (fastify) => {
150
104
  fastify.get<{
151
105
  Params: { type: string; slug: string };
@@ -153,7 +107,7 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
153
107
  }>(
154
108
  "/v1/content/:type/:slug",
155
109
  async (request, reply) => {
156
- const parsed = paramsSchema.safeParse(request.params);
110
+ const parsed = contentPageParamsSchema.safeParse(request.params);
157
111
  if (!parsed.success) {
158
112
  return reply.status(400).send({
159
113
  error: "Invalid type or slug",
@@ -161,7 +115,7 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
161
115
  });
162
116
  }
163
117
 
164
- const queryParsed = querySchema.safeParse(request.query);
118
+ const queryParsed = contentPageQuerySchema.safeParse(request.query);
165
119
  if (!queryParsed.success) {
166
120
  return reply.status(400).send({
167
121
  error: "Invalid query parameters",
@@ -191,74 +145,35 @@ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
191
145
  // Refused before any content query, so a caller asking for a body this
192
146
  // deployment cannot serve is never charged a read whose result is then
193
147
  // discarded.
194
- let readSource: MdxContentAdapter["getPageSource"] | undefined;
195
- if (include === "body") {
196
- if (!hasPageSource(adapter)) {
197
- return reply.status(400).send({
198
- error: "Invalid query parameters",
199
- details: [
200
- {
201
- path: ["include"],
202
- message: "body is not available from this content adapter",
203
- },
204
- ],
205
- });
206
- }
207
- readSource = adapter.getPageSource.bind(adapter);
148
+ if (include === "body" && !hasPageSource(adapter)) {
149
+ return reply.status(400).send({
150
+ error: "Invalid query parameters",
151
+ details: [
152
+ {
153
+ path: ["include"],
154
+ message: "body is not available from this content adapter",
155
+ },
156
+ ],
157
+ });
208
158
  }
209
159
 
210
- // #5927: ONE adapter call supplies everything the response sends.
211
- // `readSource` (bound above, only when `include=body`) returns BOTH
212
- // the frontmatter and the body from the SAME read, so the two halves
213
- // can never straddle a publish that lands between two independent
214
- // calls. Without the parameter, `getPageBySlug` is the only call, as
215
- // it always was. `source` stays `undefined` on that path — the
216
- // `source && {...}` below never attaches a body to the response.
217
- let source: MdxPageSource | null | undefined;
218
- let page: ContentPage | null;
219
- if (readSource) {
220
- source = await readSource(type as SchemaOrgType, slug, locale);
221
- page = source === null ? null : source.frontmatter;
222
- } else {
223
- page = await adapter.getPageBySlug(type as SchemaOrgType, slug, locale);
224
- }
225
- if (!page) {
160
+ // #5927: ONE adapter call supplies everything the response sends —
161
+ // the builder reads `getPageSource` only when `include=body`, and
162
+ // `getPageBySlug` alone otherwise, so the two halves can never
163
+ // straddle a publish that lands between two independent calls.
164
+ const document = await readContentDocument(
165
+ adapter,
166
+ { type, slug, locale, include },
167
+ readSiteConfig(getContentRoot()),
168
+ );
169
+ if (!document) {
226
170
  return reply.status(404).send({
227
171
  error: "Page not found",
228
172
  type,
229
173
  slug,
230
174
  });
231
175
  }
232
-
233
- // #8746 — the site's origin, from `_site.mdx`'s `baseUrl`, for a
234
- // builder that needs an absolute URL (JobPosting's ApplyAction).
235
- const origin = readSiteConfig(getContentRoot())?.baseUrl.replace(/\/+$/, "");
236
- const input: ContentPageInput = {
237
- type: page.type,
238
- slug: page.slug,
239
- title: page.title,
240
- description: page.description,
241
- ...(origin !== undefined && { origin }),
242
- ...(page.publishedAt !== undefined && {
243
- publishedAt: page.publishedAt,
244
- }),
245
- ...(page.updatedAt !== undefined && { updatedAt: page.updatedAt }),
246
- ...(page.image !== undefined && { image: page.image }),
247
- ...(page.schema !== undefined && { schema: page.schema }),
248
- // ADR-0026: carry the page's typed sections so recipes compose entity
249
- // JSON-LD from real section data (e.g. FAQPage.mainEntity from `faq`
250
- // sections) rather than a hand-rolled `schema` override. The adapter
251
- // now preserves `sections`; before, it was stripped at validation.
252
- ...(page.sections !== undefined && { sections: page.sections }),
253
- };
254
- const primary = buildJsonLd(input);
255
- const jsonLd =
256
- page.type === "HomePage" ? homePageGraph(primary, page.image) : primary;
257
- return reply.send({
258
- ...jsonLd,
259
- page,
260
- ...(source && { body: source.body }),
261
- });
176
+ return reply.send(document);
262
177
  } catch (error) {
263
178
  request.log.error({ err: error }, "[Content] getPage failed");
264
179
  return reply.status(500).send({ error: "Internal server error" });
@@ -1,9 +1,16 @@
1
1
  /**
2
2
  * Public content API.
3
3
  *
4
- * Read endpoints, all GET, all JSON-LD-shaped at the document root:
4
+ * Read endpoints, all GET, JSON-LD-shaped at the document root unless noted:
5
5
  * GET /v1/content/types — supported page types
6
6
  * GET /v1/content/search — keyword search (#5831)
7
+ * GET /v1/content/offerings — Services + Products (#7177); answers the
8
+ * `{ ok, data }` envelope, not JSON-LD
9
+ * GET /v1/content/pricing — pricing tiers (#7178); answers the
10
+ * `{ ok, data }` envelope, not JSON-LD
11
+ * GET /v1/content/shown-offerings — the /pricing page's Offerings read
12
+ * (#8924); answers the { ok, data }
13
+ * envelope, not JSON-LD
7
14
  * GET /v1/content/:type — paginated list of pages
8
15
  * GET /v1/content/:type/:slug — single page with resolved JSON-LD
9
16
  *
@@ -24,12 +31,19 @@ import type { FastifyPluginAsync } from "fastify";
24
31
  import { getPageRoute } from "./get-page.js";
25
32
  import { listPagesRoute } from "./list-pages.js";
26
33
  import { listTypesRoute } from "./list-types.js";
34
+ import { offeringsRoute } from "./offerings.js";
35
+ import { pricingRoute } from "./pricing.js";
27
36
  import { searchContentRoute } from "./search.js";
37
+ import { shownOfferingsRoute } from "./shown-offerings.js";
28
38
 
29
39
  export const contentRoutes: FastifyPluginAsync = async (fastify) => {
30
40
  await fastify.register(listTypesRoute);
31
- // Before `listPagesRoute`: `search` must never be read as a `:type`.
41
+ // Before `listPagesRoute`: `search`, `offerings`, `pricing` and
42
+ // `shown-offerings` must never be read as a `:type`.
32
43
  await fastify.register(searchContentRoute);
44
+ await fastify.register(offeringsRoute);
45
+ await fastify.register(pricingRoute);
46
+ await fastify.register(shownOfferingsRoute);
33
47
  await fastify.register(listPagesRoute);
34
48
  await fastify.register(getPageRoute);
35
49
  };
@@ -5,43 +5,32 @@
5
5
  * pageSize 20; pageSize is capped at 100 to keep response payloads bounded.
6
6
  *
7
7
  * Response envelope is an `ItemList` JSON-LD document carrying the page
8
- * frontmatter under `items` and pagination metadata alongside. A deployment
9
- * whose site cannot be resolved (#5852) answers 404, not an empty list.
8
+ * frontmatter under `items` and pagination metadata alongside, built by
9
+ * `listPagesItemList` in `@working-theory/content-adapters` — the shared seam
10
+ * the `list_pages` MCP tool answers through, parsing the same
11
+ * `@working-theory/validation` exports (#7168). A deployment whose site
12
+ * cannot be resolved (#5852) answers 404, not an empty list.
10
13
  *
11
14
  * Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
12
15
  * `readLocales()` allowlist; invalid or unknown locale silently falls back to
13
16
  * the project's default locale (AC-i18n-07).
14
17
  */
15
- import { SCHEMA_ORG_TYPES, type SchemaOrgType } from "@working-theory/content-adapters";
18
+ import { listPagesItemList } from "@working-theory/content-adapters";
19
+ import {
20
+ contentListParamsSchema,
21
+ contentListQuerySchema,
22
+ } from "@working-theory/validation";
16
23
  import type { FastifyPluginAsync } from "fastify";
17
- import { z } from "zod";
18
24
 
19
25
  import { resolveLocale } from "../../lib/locales.js";
20
26
  import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
21
27
 
22
- const paramsSchema = z.object({
23
- type: z.enum(SCHEMA_ORG_TYPES),
24
- });
25
-
26
- const PAGE_SIZE_MAX = 100;
27
-
28
- const querySchema = z.object({
29
- page: z.coerce.number().int().min(1).default(1),
30
- pageSize: z.coerce
31
- .number()
32
- .int()
33
- .min(1)
34
- .default(20)
35
- .transform((v) => Math.min(v, PAGE_SIZE_MAX)),
36
- locale: z.string().optional(),
37
- });
38
-
39
28
  export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
40
29
  fastify.get<{
41
30
  Params: { type: string };
42
31
  Querystring: { page?: string; pageSize?: string; locale?: string };
43
32
  }>("/v1/content/:type", async (request, reply) => {
44
- const params = paramsSchema.safeParse(request.params);
33
+ const params = contentListParamsSchema.safeParse(request.params);
45
34
  if (!params.success) {
46
35
  return reply.status(400).send({
47
36
  error: "Invalid type",
@@ -49,7 +38,7 @@ export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
49
38
  });
50
39
  }
51
40
 
52
- const query = querySchema.safeParse(request.query);
41
+ const query = contentListQuerySchema.safeParse(request.query);
53
42
  if (!query.success) {
54
43
  return reply.status(400).send({
55
44
  error: "Invalid query parameters",
@@ -74,19 +63,9 @@ export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
74
63
  });
75
64
  }
76
65
 
77
- const all = await adapter.listPages(type as SchemaOrgType, locale);
78
- const totalItems = all.length;
79
- const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
80
- const start = (page - 1) * pageSize;
81
- const items = all.slice(start, start + pageSize);
82
-
83
- return reply.send({
84
- "@context": "https://schema.org",
85
- "@type": "ItemList",
86
- type,
87
- items,
88
- pagination: { page, pageSize, totalItems, totalPages },
89
- });
66
+ return reply.send(
67
+ await listPagesItemList(adapter, { type, page, pageSize, locale }),
68
+ );
90
69
  } catch (error) {
91
70
  request.log.error({ err: error }, "[Content] listPages failed");
92
71
  return reply.status(500).send({ error: "Internal server error" });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * GET /v1/content/offerings
3
+ *
4
+ * The site's Services and Products as one offerings list (#7177). The HTTP
5
+ * twin of the `get_offerings` MCP tool: same arguments, parsed with the same
6
+ * `@working-theory/validation` schema, and the same result, produced by the
7
+ * same function — `listOfferings` in `@working-theory/content-adapters`, the
8
+ * shared seam the tool also answers through. That is the API ↔ MCP parity
9
+ * contract in the root CLAUDE.md; the parity guard's `get_offerings` row is a
10
+ * `twin` naming this route.
11
+ *
12
+ * Query:
13
+ * type optional — `service` or `product`; omit for both
14
+ * locale optional — BCP-47 (`xx` or `xx-XX`). A well-formed tag outside the
15
+ * project's allowlist silently falls back to the default locale
16
+ * (AC-i18n-07), matching every other content read; a malformed one
17
+ * is a 400, as the tool refuses it.
18
+ *
19
+ * Success: `{ ok: true, data: { offerings } }` — the API's documented
20
+ * envelope, not the JSON-LD `ItemList` the older content routes answer.
21
+ * Refusals: `400 validation_failed` (issues are not echoed), `404 not_found`
22
+ * when no public site resolves, `500 internal_error`.
23
+ *
24
+ * Registered BEFORE `/v1/content/:type` in `index.ts`. Fastify's radix router
25
+ * prefers a static segment over a parametric one regardless of order — the
26
+ * ordering is belt-and-braces, as it is for search.
27
+ *
28
+ * Rate limiting is inherited: `server.ts` registers the limiter GLOBALLY with
29
+ * the content ceiling, so this route is covered without one of its own.
30
+ *
31
+ * Which site this read serves is resolved BEFORE any content query (#5852)
32
+ * through the same `resolvePublicContentAdapter(request)` seam the sibling
33
+ * content reads use. The resolver reads `x-ailk-spoke-key`, so every answer
34
+ * past the query parse appends that header to `Vary`: a cache keyed on the
35
+ * URL alone must never share one site's answer — or one bad key's 404 — with
36
+ * another (SEC-7177-C). It appends rather than sets, because `@fastify/cors`
37
+ * has already written `Vary: Origin`.
38
+ *
39
+ * Offerings (#8933, spec D4): on a connected workspace with a current shown
40
+ * set, every read but `type=service` answers the Service pages followed by
41
+ * that set's Products — in place of the Product pages, since Stripe is that
42
+ * site's price authority — through `readShownOfferings` (#8924's read, the
43
+ * one code path every agent surface shares) and `listOfferingsWithShownSet`
44
+ * in `@working-theory/content-adapters`, the mapping the tool answers through
45
+ * too. Each shown Product carries `prices`: every price with its interval and
46
+ * full lookup key. `type=service`, and every workspace with no connection, no
47
+ * claim or no current set, answers page content exactly as before; a failed
48
+ * shown-set read takes the content path too. The read refreshes, or not,
49
+ * exactly as the shown-offerings route does (`options`, D5): never with the
50
+ * owner's override, never per request (T7).
51
+ *
52
+ * Anonymous and identity-blind: no `Authorization`, cookie, session or user
53
+ * id is read.
54
+ */
55
+ import {
56
+ currentShownSet,
57
+ listOfferings,
58
+ listOfferingsWithShownSet,
59
+ type ContentAdapter,
60
+ type ListOfferingsOptions,
61
+ } from "@working-theory/content-adapters";
62
+ import {
63
+ getOfferingsInputSchema,
64
+ type GetOfferingsOutput,
65
+ } from "@working-theory/validation";
66
+ import type {
67
+ FastifyPluginAsync,
68
+ FastifyReply,
69
+ FastifyRequest,
70
+ } from "fastify";
71
+
72
+ import { resolveLocale } from "../../lib/locales.js";
73
+ import { resolvePublicContentAdapter } from "../../services/public-site-resolver.js";
74
+
75
+ import {
76
+ readShownOfferings,
77
+ type ShownOfferingsRouteOptions,
78
+ } from "./shown-offerings.js";
79
+
80
+ /** The request header the resolver picks the site's workspace from. */
81
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
82
+
83
+ /** Adds the spoke-key header to `Vary`, keeping every value already there. */
84
+ function varyOnSpokeKey(reply: FastifyReply): void {
85
+ const existing = reply.getHeader("vary");
86
+ const values = (
87
+ Array.isArray(existing) ? existing.join(",") : String(existing ?? "")
88
+ )
89
+ .split(",")
90
+ .map((value) => value.trim())
91
+ .filter((value) => value !== "");
92
+ if (!values.some((value) => value.toLowerCase() === SPOKE_KEY_HEADER)) {
93
+ values.push(SPOKE_KEY_HEADER);
94
+ }
95
+ reply.header("Vary", values.join(", "));
96
+ }
97
+
98
+ /** An error's class name only — never its message, which may carry Stripe text. */
99
+ function errName(err: unknown): string {
100
+ return err instanceof Error ? err.constructor.name : typeof err;
101
+ }
102
+
103
+ /**
104
+ * The page Services followed by the connected workspace's shown Products, or
105
+ * `null` for the content path (#8933 D4). A failed read or mapping is one
106
+ * fixed `warn` line and the content path, never a 500 and never an `{ err }`
107
+ * line.
108
+ */
109
+ async function shownSetOfferings(
110
+ request: FastifyRequest,
111
+ options: ShownOfferingsRouteOptions,
112
+ adapter: ContentAdapter,
113
+ listOptions: ListOfferingsOptions,
114
+ ): Promise<GetOfferingsOutput | null> {
115
+ try {
116
+ const shownSet = currentShownSet(
117
+ await readShownOfferings(request, options),
118
+ );
119
+ return shownSet === null
120
+ ? null
121
+ : await listOfferingsWithShownSet(adapter, listOptions, shownSet);
122
+ } catch (err) {
123
+ request.log.warn(
124
+ { outcome: "shown_set_failed", errName: errName(err) },
125
+ "[Content] offerings: shown set unavailable, answering page content",
126
+ );
127
+ return null;
128
+ }
129
+ }
130
+
131
+ export const offeringsRoute: FastifyPluginAsync<
132
+ ShownOfferingsRouteOptions
133
+ > = async (fastify, options) => {
134
+ fastify.get<{
135
+ Querystring: { type?: string; locale?: string };
136
+ }>("/v1/content/offerings", async (request, reply) => {
137
+ const query = getOfferingsInputSchema.safeParse(request.query);
138
+ if (!query.success) {
139
+ return reply.status(400).send({
140
+ ok: false,
141
+ error: {
142
+ code: "validation_failed",
143
+ message: "Invalid query parameters",
144
+ },
145
+ });
146
+ }
147
+
148
+ const locale = resolveLocale(query.data.locale);
149
+ // Every answer from here on depends on the spoke key — the 404 and 500
150
+ // included — so each carries the Vary.
151
+ varyOnSpokeKey(reply);
152
+
153
+ try {
154
+ const adapter = await resolvePublicContentAdapter(request);
155
+ if (adapter === null) {
156
+ return reply.status(404).send({
157
+ ok: false,
158
+ error: { code: "not_found", message: "Page not found" },
159
+ });
160
+ }
161
+
162
+ if (query.data.type !== "service") {
163
+ const shown = await shownSetOfferings(request, options, adapter, {
164
+ type: query.data.type,
165
+ locale,
166
+ });
167
+ if (shown !== null) return reply.send({ ok: true, data: shown });
168
+ }
169
+
170
+ const data = await listOfferings(adapter, {
171
+ type: query.data.type,
172
+ locale,
173
+ });
174
+
175
+ return reply.send({ ok: true, data });
176
+ } catch (error) {
177
+ request.log.error({ err: error }, "[Content] listOfferings failed");
178
+ return reply.status(500).send({
179
+ ok: false,
180
+ error: { code: "internal_error", message: "Internal server error" },
181
+ });
182
+ }
183
+ });
184
+ };