create-ailk 0.1.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 (533) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +39 -0
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +80 -0
  5. package/dist/copy.d.ts +8 -0
  6. package/dist/copy.js +109 -0
  7. package/dist/index.d.ts +10 -0
  8. package/dist/index.js +10 -0
  9. package/dist/lib/pro-callout.d.ts +12 -0
  10. package/dist/lib/pro-callout.js +20 -0
  11. package/dist/parse-args.d.ts +13 -0
  12. package/dist/parse-args.js +81 -0
  13. package/dist/programmatic.d.ts +49 -0
  14. package/dist/programmatic.js +89 -0
  15. package/dist/substitute.d.ts +31 -0
  16. package/dist/substitute.js +45 -0
  17. package/dist/validate.d.ts +8 -0
  18. package/dist/validate.js +70 -0
  19. package/package.json +47 -0
  20. package/templates/.claude/.gitkeep +0 -0
  21. package/templates/.claude/agents/web.md +80 -0
  22. package/templates/.claude/rules/architecture.md +62 -0
  23. package/templates/.claude/rules/preserve-existing-style.md +51 -0
  24. package/templates/.claude/skills/README.md +43 -0
  25. package/templates/.claude/skills/add-schema/SKILL.md +270 -0
  26. package/templates/.claude/skills/add-schema/references/conflict-handling.md +200 -0
  27. package/templates/.claude/skills/add-schema/references/file-finding.md +123 -0
  28. package/templates/.claude/skills/add-schema/references/schema-types.md +131 -0
  29. package/templates/.claude/skills/audit-site/SKILL.md +213 -0
  30. package/templates/.claude/skills/audit-site/references/common-errors.md +113 -0
  31. package/templates/.claude/skills/audit-site/references/findings-glossary.md +138 -0
  32. package/templates/.claude/skills/audit-site/references/severity-levels.md +114 -0
  33. package/templates/.claude/skills/provision-config/SKILL.md +243 -0
  34. package/templates/.claude/skills/provision-config/references/env-vars-template.md +184 -0
  35. package/templates/.claude/skills/provision-config/references/runbook-template.md +248 -0
  36. package/templates/.claude/skills/provision-config/references/vercel-json-template.md +98 -0
  37. package/templates/.claude/skills/scaffold-auth/SKILL.md +261 -0
  38. package/templates/.claude/skills/scaffold-auth/references/env-vars.md +91 -0
  39. package/templates/.claude/skills/scaffold-auth/references/oauth-app-setup.md +171 -0
  40. package/templates/.claude/skills/scaffold-commerce/SKILL.md +310 -0
  41. package/templates/.claude/skills/scaffold-commerce/references/lookup-keys-template.md +71 -0
  42. package/templates/.claude/skills/scaffold-commerce/references/marketplace-brand-pages.md +83 -0
  43. package/templates/.claude/skills/scaffold-commerce/templates/marketplace-cta-route.tsx.tmpl +72 -0
  44. package/templates/.claude/skills/scaffold-page/SKILL.md +243 -0
  45. package/templates/.claude/skills/scaffold-page/references/page-types.md +169 -0
  46. package/templates/.claude/skills/scaffold-page/templates/content.mdx.tmpl +28 -0
  47. package/templates/.claude/skills/scaffold-page/templates/route.tsx.tmpl +28 -0
  48. package/templates/.claude/skills/suggest-site-pages/SKILL.md +242 -0
  49. package/templates/.claude/skills/suggest-site-pages/references/categories.md +287 -0
  50. package/templates/.env.example +205 -0
  51. package/templates/.gitkeep +2 -0
  52. package/templates/CLAUDE.md +142 -0
  53. package/templates/CONVENTIONS.md +222 -0
  54. package/templates/README.md +110 -0
  55. package/templates/apps/api/.env.example +40 -0
  56. package/templates/apps/api/CLAUDE.md +63 -0
  57. package/templates/apps/api/api/index.ts +144 -0
  58. package/templates/apps/api/jest.config.cjs +74 -0
  59. package/templates/apps/api/package.json +54 -0
  60. package/templates/apps/api/src/__tests__/factories/content-fixture.ts +138 -0
  61. package/templates/apps/api/src/__tests__/factories/index.ts +70 -0
  62. package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +82 -0
  63. package/templates/apps/api/src/__tests__/index.test.ts +118 -0
  64. package/templates/apps/api/src/__tests__/server.test.ts +207 -0
  65. package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +126 -0
  66. package/templates/apps/api/src/__tests__/setup.ts +24 -0
  67. package/templates/apps/api/src/cli.ts +30 -0
  68. package/templates/apps/api/src/config/__tests__/index.test.ts +97 -0
  69. package/templates/apps/api/src/config/index.ts +56 -0
  70. package/templates/apps/api/src/index.ts +32 -0
  71. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +15 -0
  72. package/templates/apps/api/src/lib/__mocks__/prisma.ts +45 -0
  73. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +178 -0
  74. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +100 -0
  75. package/templates/apps/api/src/lib/__tests__/errors.test.ts +94 -0
  76. package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +144 -0
  77. package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +8 -0
  78. package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +227 -0
  79. package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/prisma.test.ts +165 -0
  81. package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +129 -0
  82. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +74 -0
  83. package/templates/apps/api/src/lib/content-adapter.ts +86 -0
  84. package/templates/apps/api/src/lib/error-serializer.ts +77 -0
  85. package/templates/apps/api/src/lib/errors.ts +95 -0
  86. package/templates/apps/api/src/lib/file-fallback.ts +87 -0
  87. package/templates/apps/api/src/lib/lead-capture-store.ts +110 -0
  88. package/templates/apps/api/src/lib/locales.ts +201 -0
  89. package/templates/apps/api/src/lib/outbound-http.ts +122 -0
  90. package/templates/apps/api/src/lib/prisma.ts +21 -0
  91. package/templates/apps/api/src/lib/require-paid-tier.ts +53 -0
  92. package/templates/apps/api/src/lib/stripe.ts +30 -0
  93. package/templates/apps/api/src/lib/webhook-idempotency.ts +92 -0
  94. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +111 -0
  95. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +172 -0
  96. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +119 -0
  97. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +138 -0
  98. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +149 -0
  99. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +139 -0
  100. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +175 -0
  101. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +76 -0
  102. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +153 -0
  103. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +72 -0
  104. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +292 -0
  105. package/templates/apps/api/src/middleware/auth.ts +246 -0
  106. package/templates/apps/api/src/middleware/rate-limit.ts +132 -0
  107. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +129 -0
  108. package/templates/apps/api/src/openapi/index.ts +12 -0
  109. package/templates/apps/api/src/openapi/spec.ts +408 -0
  110. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +193 -0
  111. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +201 -0
  112. package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +294 -0
  113. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +486 -0
  114. package/templates/apps/api/src/routes/auth/tokens.ts +199 -0
  115. package/templates/apps/api/src/routes/auth/user.ts +244 -0
  116. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +588 -0
  117. package/templates/apps/api/src/routes/checkout/index.ts +13 -0
  118. package/templates/apps/api/src/routes/checkout/sessions.ts +185 -0
  119. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +239 -0
  120. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +201 -0
  121. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +141 -0
  122. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +122 -0
  123. package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +44 -0
  124. package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +118 -0
  125. package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +51 -0
  126. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +252 -0
  127. package/templates/apps/api/src/routes/content/create.ts +114 -0
  128. package/templates/apps/api/src/routes/content/delete.ts +81 -0
  129. package/templates/apps/api/src/routes/content/get-page.ts +98 -0
  130. package/templates/apps/api/src/routes/content/index.ts +32 -0
  131. package/templates/apps/api/src/routes/content/list-pages.ts +83 -0
  132. package/templates/apps/api/src/routes/content/list-types.ts +19 -0
  133. package/templates/apps/api/src/routes/content/rate-limit.ts +13 -0
  134. package/templates/apps/api/src/routes/content/slug.ts +9 -0
  135. package/templates/apps/api/src/routes/content/update.ts +111 -0
  136. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +415 -0
  137. package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +165 -0
  138. package/templates/apps/api/src/routes/leads/index.ts +130 -0
  139. package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +296 -0
  140. package/templates/apps/api/src/routes/test/auth/session.ts +141 -0
  141. package/templates/apps/api/src/routes/webhooks/README.md +57 -0
  142. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +247 -0
  143. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +198 -0
  144. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +633 -0
  145. package/templates/apps/api/src/routes/webhooks/index.ts +41 -0
  146. package/templates/apps/api/src/routes/webhooks/stripe.ts +253 -0
  147. package/templates/apps/api/src/server.ts +157 -0
  148. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +395 -0
  149. package/templates/apps/api/src/services/enrichment-shim.ts +53 -0
  150. package/templates/apps/api/src/services/lead-routing.ts +338 -0
  151. package/templates/apps/api/tsconfig.json +34 -0
  152. package/templates/apps/api/vercel.json +17 -0
  153. package/templates/apps/mcp/.env.example +52 -0
  154. package/templates/apps/mcp/CLAUDE.md +84 -0
  155. package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +52 -0
  156. package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +46 -0
  157. package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +36 -0
  158. package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +54 -0
  159. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +110 -0
  160. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +213 -0
  161. package/templates/apps/mcp/__tests__/audit.test.ts +217 -0
  162. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +52 -0
  163. package/templates/apps/mcp/__tests__/create_page.test.ts +201 -0
  164. package/templates/apps/mcp/__tests__/delete_page.test.ts +176 -0
  165. package/templates/apps/mcp/__tests__/get_business_hours.test.ts +82 -0
  166. package/templates/apps/mcp/__tests__/get_faq.test.ts +80 -0
  167. package/templates/apps/mcp/__tests__/get_locations.test.ts +65 -0
  168. package/templates/apps/mcp/__tests__/get_offerings.test.ts +90 -0
  169. package/templates/apps/mcp/__tests__/get_pricing.test.ts +80 -0
  170. package/templates/apps/mcp/__tests__/get_site_info.test.ts +81 -0
  171. package/templates/apps/mcp/__tests__/http-transport.test.ts +323 -0
  172. package/templates/apps/mcp/__tests__/lib/errors.test.ts +75 -0
  173. package/templates/apps/mcp/__tests__/lib/require-paid-tier.test.ts +75 -0
  174. package/templates/apps/mcp/__tests__/list_pages.test.ts +112 -0
  175. package/templates/apps/mcp/__tests__/locale.test.ts +135 -0
  176. package/templates/apps/mcp/__tests__/parity.test.ts +205 -0
  177. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +34 -0
  178. package/templates/apps/mcp/__tests__/read_content.test.ts +153 -0
  179. package/templates/apps/mcp/__tests__/request_callback.test.ts +163 -0
  180. package/templates/apps/mcp/__tests__/search_content.test.ts +153 -0
  181. package/templates/apps/mcp/__tests__/server.test.ts +115 -0
  182. package/templates/apps/mcp/__tests__/submit_lead.test.ts +165 -0
  183. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +161 -0
  184. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +227 -0
  185. package/templates/apps/mcp/__tests__/tools/scaffold.test.ts +237 -0
  186. package/templates/apps/mcp/__tests__/update_page.test.ts +195 -0
  187. package/templates/apps/mcp/jest.config.cjs +82 -0
  188. package/templates/apps/mcp/package.json +37 -0
  189. package/templates/apps/mcp/src/abuse/daily-cap.ts +47 -0
  190. package/templates/apps/mcp/src/abuse/email-validator.ts +25 -0
  191. package/templates/apps/mcp/src/abuse/honeypot.ts +21 -0
  192. package/templates/apps/mcp/src/abuse/index.ts +111 -0
  193. package/templates/apps/mcp/src/abuse/user-agent-deny.ts +58 -0
  194. package/templates/apps/mcp/src/http-transport.ts +303 -0
  195. package/templates/apps/mcp/src/index.ts +67 -0
  196. package/templates/apps/mcp/src/lib/audit-single-page.ts +194 -0
  197. package/templates/apps/mcp/src/lib/errors.ts +61 -0
  198. package/templates/apps/mcp/src/lib/locales.ts +171 -0
  199. package/templates/apps/mcp/src/lib/require-paid-tier.ts +44 -0
  200. package/templates/apps/mcp/src/lib/resolve-locale.ts +30 -0
  201. package/templates/apps/mcp/src/server.ts +139 -0
  202. package/templates/apps/mcp/src/tools/audit.ts +103 -0
  203. package/templates/apps/mcp/src/tools/capture_lead.ts +52 -0
  204. package/templates/apps/mcp/src/tools/create_page.ts +95 -0
  205. package/templates/apps/mcp/src/tools/delete_page.ts +73 -0
  206. package/templates/apps/mcp/src/tools/get_business_hours.ts +66 -0
  207. package/templates/apps/mcp/src/tools/get_faq.ts +69 -0
  208. package/templates/apps/mcp/src/tools/get_locations.ts +85 -0
  209. package/templates/apps/mcp/src/tools/get_offerings.ts +66 -0
  210. package/templates/apps/mcp/src/tools/get_pricing.ts +74 -0
  211. package/templates/apps/mcp/src/tools/get_site_info.ts +84 -0
  212. package/templates/apps/mcp/src/tools/index.ts +125 -0
  213. package/templates/apps/mcp/src/tools/list_pages.ts +72 -0
  214. package/templates/apps/mcp/src/tools/purchase.ts +89 -0
  215. package/templates/apps/mcp/src/tools/read_content.ts +61 -0
  216. package/templates/apps/mcp/src/tools/request_callback.ts +51 -0
  217. package/templates/apps/mcp/src/tools/scaffold.ts +119 -0
  218. package/templates/apps/mcp/src/tools/search_content.ts +59 -0
  219. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +47 -0
  220. package/templates/apps/mcp/src/tools/update_page.ts +91 -0
  221. package/templates/apps/mcp/tsconfig.json +19 -0
  222. package/templates/apps/web/.env.example +47 -0
  223. package/templates/apps/web/CLAUDE.md +81 -0
  224. package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +94 -0
  225. package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +75 -0
  226. package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +82 -0
  227. package/templates/apps/web/__tests__/auth/env-helpers.test.ts +168 -0
  228. package/templates/apps/web/__tests__/authority-list-routes.test.tsx +208 -0
  229. package/templates/apps/web/__tests__/better-stack.test.ts +66 -0
  230. package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +87 -0
  231. package/templates/apps/web/__tests__/blog-source-populated.test.ts +130 -0
  232. package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +60 -0
  233. package/templates/apps/web/__tests__/google-analytics.test.tsx +104 -0
  234. package/templates/apps/web/__tests__/i18n/middleware.test.ts +119 -0
  235. package/templates/apps/web/__tests__/i18n/routing.test.ts +114 -0
  236. package/templates/apps/web/__tests__/i18n/translations.test.ts +191 -0
  237. package/templates/apps/web/__tests__/json-ld.test.ts +98 -0
  238. package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +580 -0
  239. package/templates/apps/web/__tests__/layout-stealth.test.ts +39 -0
  240. package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +103 -0
  241. package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +114 -0
  242. package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +94 -0
  243. package/templates/apps/web/__tests__/page.test.tsx +230 -0
  244. package/templates/apps/web/__tests__/products-services-phase1.test.tsx +321 -0
  245. package/templates/apps/web/__tests__/projects-routes.test.tsx +392 -0
  246. package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +475 -0
  247. package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +53 -0
  248. package/templates/apps/web/__tests__/routes/blog-route.test.tsx +191 -0
  249. package/templates/apps/web/__tests__/routes/llms-txt.test.ts +123 -0
  250. package/templates/apps/web/__tests__/routes/robots-txt.test.ts +52 -0
  251. package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +78 -0
  252. package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +203 -0
  253. package/templates/apps/web/__tests__/section-routes.test.tsx +693 -0
  254. package/templates/apps/web/__tests__/security-headers.test.ts +201 -0
  255. package/templates/apps/web/__tests__/sentry-config.test.ts +116 -0
  256. package/templates/apps/web/__tests__/setup.ts +82 -0
  257. package/templates/apps/web/__tests__/sitemap-locales.test.ts +415 -0
  258. package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +844 -0
  259. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +81 -0
  260. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +110 -0
  261. package/templates/apps/web/app/.well-known/mcp.json/route.ts +58 -0
  262. package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +120 -0
  263. package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +202 -0
  264. package/templates/apps/web/app/[locale]/about/page.tsx +31 -0
  265. package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +173 -0
  266. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +45 -0
  267. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +117 -0
  268. package/templates/apps/web/app/[locale]/careers/page.tsx +25 -0
  269. package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +44 -0
  270. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +113 -0
  271. package/templates/apps/web/app/[locale]/case-studies/page.tsx +25 -0
  272. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +63 -0
  273. package/templates/apps/web/app/[locale]/clients/page.tsx +27 -0
  274. package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +130 -0
  275. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +152 -0
  276. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +92 -0
  277. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +79 -0
  278. package/templates/apps/web/app/[locale]/contact/error.tsx +24 -0
  279. package/templates/apps/web/app/[locale]/contact/page.tsx +38 -0
  280. package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +34 -0
  281. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +119 -0
  282. package/templates/apps/web/app/[locale]/courses/page.tsx +25 -0
  283. package/templates/apps/web/app/[locale]/dev/purchase/__tests__/actions.test.ts +160 -0
  284. package/templates/apps/web/app/[locale]/dev/purchase/error.tsx +25 -0
  285. package/templates/apps/web/app/[locale]/dev/purchase/page.tsx +117 -0
  286. package/templates/apps/web/app/[locale]/dev/purchase/purchase.actions.ts +58 -0
  287. package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +118 -0
  288. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +72 -0
  289. package/templates/apps/web/app/[locale]/downloads/page.tsx +25 -0
  290. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +47 -0
  291. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +130 -0
  292. package/templates/apps/web/app/[locale]/events/page.tsx +25 -0
  293. package/templates/apps/web/app/[locale]/faq/page.tsx +36 -0
  294. package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +34 -0
  295. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +78 -0
  296. package/templates/apps/web/app/[locale]/how-to/page.tsx +40 -0
  297. package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +43 -0
  298. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +129 -0
  299. package/templates/apps/web/app/[locale]/integrations/page.tsx +25 -0
  300. package/templates/apps/web/app/[locale]/launch-service/page.tsx +31 -0
  301. package/templates/apps/web/app/[locale]/layout.tsx +241 -0
  302. package/templates/apps/web/app/[locale]/page.tsx +41 -0
  303. package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +44 -0
  304. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +113 -0
  305. package/templates/apps/web/app/[locale]/partners/page.tsx +25 -0
  306. package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +43 -0
  307. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +129 -0
  308. package/templates/apps/web/app/[locale]/products/page.tsx +25 -0
  309. package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +39 -0
  310. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +138 -0
  311. package/templates/apps/web/app/[locale]/projects/page.tsx +30 -0
  312. package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +51 -0
  313. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +117 -0
  314. package/templates/apps/web/app/[locale]/resources/page.tsx +27 -0
  315. package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +43 -0
  316. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +129 -0
  317. package/templates/apps/web/app/[locale]/services/page.tsx +25 -0
  318. package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +34 -0
  319. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +81 -0
  320. package/templates/apps/web/app/[locale]/software/page.tsx +39 -0
  321. package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +34 -0
  322. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +82 -0
  323. package/templates/apps/web/app/[locale]/tech-articles/page.tsx +39 -0
  324. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +63 -0
  325. package/templates/apps/web/app/[locale]/testimonials/page.tsx +27 -0
  326. package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +63 -0
  327. package/templates/apps/web/app/[locale]/use-cases/page.tsx +27 -0
  328. package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +34 -0
  329. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +82 -0
  330. package/templates/apps/web/app/[locale]/whitepapers/page.tsx +39 -0
  331. package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +152 -0
  332. package/templates/apps/web/app/api/auth/[...all]/route.ts +16 -0
  333. package/templates/apps/web/app/error-fallback.tsx +75 -0
  334. package/templates/apps/web/app/error.tsx +23 -0
  335. package/templates/apps/web/app/layout.tsx +18 -0
  336. package/templates/apps/web/app/llms.txt/route.ts +101 -0
  337. package/templates/apps/web/app/not-found.tsx +48 -0
  338. package/templates/apps/web/app/og/__tests__/route.test.ts +164 -0
  339. package/templates/apps/web/app/og/route.tsx +135 -0
  340. package/templates/apps/web/app/robots.txt/route.ts +36 -0
  341. package/templates/apps/web/app/sitemap.xml/route.ts +146 -0
  342. package/templates/apps/web/auth.ts +141 -0
  343. package/templates/apps/web/components/ThemeToggle.tsx +60 -0
  344. package/templates/apps/web/components/google-analytics.tsx +34 -0
  345. package/templates/apps/web/content/en/blog/index.mdx +15 -0
  346. package/templates/apps/web/content/en/blog/launching-ailk.mdx +20 -0
  347. package/templates/apps/web/content/en/docs/getting-started.mdx +30 -0
  348. package/templates/apps/web/i18n/request.ts +36 -0
  349. package/templates/apps/web/jest.config.cjs +100 -0
  350. package/templates/apps/web/lib/__mocks__/next-mdx-remote-rsc.js +12 -0
  351. package/templates/apps/web/lib/__mocks__/source-server.js +31 -0
  352. package/templates/apps/web/lib/__tests__/og.test.ts +65 -0
  353. package/templates/apps/web/lib/auth-env.ts +66 -0
  354. package/templates/apps/web/lib/blog-list-data.ts +95 -0
  355. package/templates/apps/web/lib/blog-post-data.ts +79 -0
  356. package/templates/apps/web/lib/nav-config.ts +13 -0
  357. package/templates/apps/web/lib/observability/better-stack.ts +27 -0
  358. package/templates/apps/web/lib/og.ts +23 -0
  359. package/templates/apps/web/lib/prisma-for-auth.ts +34 -0
  360. package/templates/apps/web/lib/source.ts +34 -0
  361. package/templates/apps/web/messages/en.json +131 -0
  362. package/templates/apps/web/middleware.ts +80 -0
  363. package/templates/apps/web/next.config.mjs +167 -0
  364. package/templates/apps/web/package.json +62 -0
  365. package/templates/apps/web/public/.well-known/security.txt +17 -0
  366. package/templates/apps/web/public/android-chrome-192x192.png +0 -0
  367. package/templates/apps/web/public/android-chrome-512x512.png +0 -0
  368. package/templates/apps/web/public/apple-touch-icon.png +0 -0
  369. package/templates/apps/web/public/favicon-16x16.png +0 -0
  370. package/templates/apps/web/public/favicon-32x32.png +0 -0
  371. package/templates/apps/web/public/favicon.ico +0 -0
  372. package/templates/apps/web/public/favicon.svg +4 -0
  373. package/templates/apps/web/public/logo-dark.svg +21 -0
  374. package/templates/apps/web/public/logo-light.svg +21 -0
  375. package/templates/apps/web/public/logomark-dark.svg +4 -0
  376. package/templates/apps/web/public/logomark-light.svg +4 -0
  377. package/templates/apps/web/public/og-default.png +0 -0
  378. package/templates/apps/web/public/site.webmanifest +19 -0
  379. package/templates/apps/web/scripts/assert-content-bundled.ts +55 -0
  380. package/templates/apps/web/security-headers.config.ts +132 -0
  381. package/templates/apps/web/sentry.client.config.ts +27 -0
  382. package/templates/apps/web/sentry.edge.config.ts +15 -0
  383. package/templates/apps/web/sentry.server.config.ts +20 -0
  384. package/templates/apps/web/source.config.ts +78 -0
  385. package/templates/apps/web/src/components/SiteNav.tsx +21 -0
  386. package/templates/apps/web/src/lib/routes.ts +37 -0
  387. package/templates/apps/web/tsconfig.json +35 -0
  388. package/templates/apps/web/types/messages.d.ts +19 -0
  389. package/templates/content/.gitkeep +0 -0
  390. package/templates/content/CLAUDE.md +85 -0
  391. package/templates/content/_site.mdx +38 -0
  392. package/templates/content/ar/HomePage/home.mdx +123 -0
  393. package/templates/content/ar/_site.mdx +35 -0
  394. package/templates/content/de/_site.mdx +29 -0
  395. package/templates/content/en/.gitkeep +0 -0
  396. package/templates/content/en/AboutPage/about.mdx +19 -0
  397. package/templates/content/en/AboutPage/about.schema.json +82 -0
  398. package/templates/content/en/BookCall/book-call.mdx +19 -0
  399. package/templates/content/en/BookCall/book-call.schema.json +121 -0
  400. package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +20 -0
  401. package/templates/content/en/CareersList/careers.mdx +24 -0
  402. package/templates/content/en/CaseStudiesList/case-studies.mdx +22 -0
  403. package/templates/content/en/CaseStudiesList/showcase.mdx +8 -0
  404. package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +32 -0
  405. package/templates/content/en/CaseStudy/schema-builder-migration.mdx +41 -0
  406. package/templates/content/en/ClientsList/clients.mdx +38 -0
  407. package/templates/content/en/ContactPage/contact.mdx +19 -0
  408. package/templates/content/en/ContactPage/contact.schema.json +100 -0
  409. package/templates/content/en/ContactPoint/primary.mdx +14 -0
  410. package/templates/content/en/Course/aeo-fundamentals.mdx +37 -0
  411. package/templates/content/en/Course/schema-builder-workshop.mdx +62 -0
  412. package/templates/content/en/CoursesList/courses.mdx +22 -0
  413. package/templates/content/en/DownloadsList/downloads.mdx +22 -0
  414. package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +39 -0
  415. package/templates/content/en/Event/ailk-launch-day-2026.mdx +35 -0
  416. package/templates/content/en/EventsList/events.mdx +26 -0
  417. package/templates/content/en/FAQPage/faq.mdx +420 -0
  418. package/templates/content/en/FAQPage/faq.schema.json +231 -0
  419. package/templates/content/en/GlossaryList/glossary.mdx +19 -0
  420. package/templates/content/en/GlossaryList/glossary.schema.json +300 -0
  421. package/templates/content/en/HomePage/home.mdx +312 -0
  422. package/templates/content/en/HomePage/home.schema.json +88 -0
  423. package/templates/content/en/HomePage/launch-service.mdx +475 -0
  424. package/templates/content/en/HomePage/launch-service.schema.json +138 -0
  425. package/templates/content/en/HowTo/add-mdx-content.mdx +38 -0
  426. package/templates/content/en/HowTo/configure-aeo-scoring.mdx +35 -0
  427. package/templates/content/en/HowTo/set-up-json-ld.mdx +36 -0
  428. package/templates/content/en/Integration/github-actions.mdx +31 -0
  429. package/templates/content/en/Integration/openai-api.mdx +26 -0
  430. package/templates/content/en/Integration/vercel.mdx +32 -0
  431. package/templates/content/en/IntegrationsList/integrations.mdx +22 -0
  432. package/templates/content/en/JobPosting/content-strategist.mdx +46 -0
  433. package/templates/content/en/JobPosting/developer-advocate.mdx +47 -0
  434. package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +57 -0
  435. package/templates/content/en/LandingPage/aeo-audit.mdx +19 -0
  436. package/templates/content/en/LandingPage/aeo-audit.schema.json +92 -0
  437. package/templates/content/en/LandingPage/agency-waitlist.mdx +340 -0
  438. package/templates/content/en/LandingPage/agency-waitlist.schema.json +152 -0
  439. package/templates/content/en/LandingPage/developers.mdx +19 -0
  440. package/templates/content/en/LandingPage/developers.schema.json +109 -0
  441. package/templates/content/en/LandingPage/for-agencies.mdx +390 -0
  442. package/templates/content/en/LandingPage/for-agencies.schema.json +107 -0
  443. package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +417 -0
  444. package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +105 -0
  445. package/templates/content/en/LandingPage/for-businesses.mdx +19 -0
  446. package/templates/content/en/LandingPage/for-businesses.schema.json +96 -0
  447. package/templates/content/en/LandingPage/platform.mdx +369 -0
  448. package/templates/content/en/LandingPage/platform.schema.json +166 -0
  449. package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +19 -0
  450. package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +102 -0
  451. package/templates/content/en/LandingPage/vs-wordpress.mdx +19 -0
  452. package/templates/content/en/LandingPage/vs-wordpress.schema.json +53 -0
  453. package/templates/content/en/NewsletterSignup/newsletter.mdx +236 -0
  454. package/templates/content/en/NewsletterSignup/newsletter.schema.json +91 -0
  455. package/templates/content/en/Organization/site-owner.mdx +16 -0
  456. package/templates/content/en/Partner/prisma.mdx +31 -0
  457. package/templates/content/en/Partner/supabase.mdx +30 -0
  458. package/templates/content/en/Partner/vercel.mdx +28 -0
  459. package/templates/content/en/PartnersList/partners.mdx +22 -0
  460. package/templates/content/en/Person/founder.mdx +18 -0
  461. package/templates/content/en/Pricing/pricing.mdx +538 -0
  462. package/templates/content/en/Pricing/pricing.schema.json +209 -0
  463. package/templates/content/en/PrivacyPage/privacy.mdx +165 -0
  464. package/templates/content/en/PrivacyPage/privacy.schema.json +64 -0
  465. package/templates/content/en/Product/ailk.mdx +19 -0
  466. package/templates/content/en/Product/ailk.schema.json +94 -0
  467. package/templates/content/en/Product/api-launch-kit.mdx +26 -0
  468. package/templates/content/en/Product/launch-kit.mdx +28 -0
  469. package/templates/content/en/ProductsList/products.mdx +26 -0
  470. package/templates/content/en/Project/ailk-phase-2.mdx +54 -0
  471. package/templates/content/en/ProjectsList/projects.mdx +25 -0
  472. package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +27 -0
  473. package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +28 -0
  474. package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +39 -0
  475. package/templates/content/en/ResourcesHub/resources.mdx +22 -0
  476. package/templates/content/en/Service/consulting.mdx +22 -0
  477. package/templates/content/en/Service/implementation.mdx +30 -0
  478. package/templates/content/en/Service/launch-service.mdx +232 -0
  479. package/templates/content/en/Service/launch.mdx +228 -0
  480. package/templates/content/en/ServicesList/services.mdx +84 -0
  481. package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +44 -0
  482. package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +47 -0
  483. package/templates/content/en/SoftwareProduct/ailk-schema.mdx +49 -0
  484. package/templates/content/en/TeamList/team.mdx +63 -0
  485. package/templates/content/en/TeamMember/founder.mdx +72 -0
  486. package/templates/content/en/TermsPage/terms.mdx +19 -0
  487. package/templates/content/en/TermsPage/terms.schema.json +99 -0
  488. package/templates/content/en/TestimonialsList/testimonials.mdx +44 -0
  489. package/templates/content/en/UseCasesList/use-cases.mdx +30 -0
  490. package/templates/content/en/Whitepaper/ax-first-product-development.mdx +27 -0
  491. package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +27 -0
  492. package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +31 -0
  493. package/templates/content/en-XA/AboutPage/about.mdx +8 -0
  494. package/templates/content/en-XA/FAQPage/faq.mdx +8 -0
  495. package/templates/content/en-XA/HomePage/home.mdx +8 -0
  496. package/templates/content/es/_site.mdx +30 -0
  497. package/templates/content/fr/_site.mdx +30 -0
  498. package/templates/content/ja/_site.mdx +30 -0
  499. package/templates/content/pt-BR/AboutPage/about.mdx +8 -0
  500. package/templates/content/pt-BR/FAQPage/faq.mdx +8 -0
  501. package/templates/content/pt-BR/HomePage/home.mdx +23 -0
  502. package/templates/database/CHANGELOG.md +5 -0
  503. package/templates/database/README.md +234 -0
  504. package/templates/database/__tests__/user-cascade.test.ts +125 -0
  505. package/templates/database/auth/.gitkeep +0 -0
  506. package/templates/database/config/.gitkeep +0 -0
  507. package/templates/database/content/.gitkeep +0 -0
  508. package/templates/database/inbox/.gitkeep +0 -0
  509. package/templates/database/inbox/README.md +3 -0
  510. package/templates/database/inbox/__tests__/schema.test.ts +40 -0
  511. package/templates/database/inbox/schema.prisma +52 -0
  512. package/templates/database/jest.config.cjs +29 -0
  513. package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +328 -0
  514. package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +100 -0
  515. package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +28 -0
  516. package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +25 -0
  517. package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +42 -0
  518. package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +67 -0
  519. package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +21 -0
  520. package/templates/database/migrations/migration_lock.toml +3 -0
  521. package/templates/database/ops/.gitkeep +0 -0
  522. package/templates/database/package.json +45 -0
  523. package/templates/database/prisma.config.ts +20 -0
  524. package/templates/database/schema.prisma +487 -0
  525. package/templates/database/scripts/db-generate-locked.sh +80 -0
  526. package/templates/database/seed-test.ts +85 -0
  527. package/templates/database/tsconfig.build.json +16 -0
  528. package/templates/database/tsconfig.json +9 -0
  529. package/templates/package.json +125 -0
  530. package/templates/pnpm-workspace.yaml +6 -0
  531. package/templates/project.yaml +59 -0
  532. package/templates/tsconfig.base.json +18 -0
  533. package/templates/tsconfig.json +45 -0
@@ -0,0 +1,252 @@
1
+ /**
2
+ * @file update.test.ts
3
+ * PATCH /api/content/:type/:slug — D14a Stage 1 Unit B.
4
+ *
5
+ * Covers: Pro-tier gating (402), missing If-Match (409), stale If-Match (409
6
+ * with current version echoed), validation (400), happy path (200 with
7
+ * {page, version}), not-found (404), unknown :type (400).
8
+ *
9
+ * Issue #398 follow-up: bespoke version-probe + getPageBySlug pre-check
10
+ * removed; the adapter throws `AdapterWriteError("not_found" | "conflict")`
11
+ * and the hoisted `mapAdapterError` translates it.
12
+ *
13
+ * Uses the real content adapter backed by a per-test temp dir (Issue #507).
14
+ * @working-theory/license stays mocked — it is an external package boundary.
15
+ */
16
+
17
+ import type { FastifyInstance } from "fastify";
18
+
19
+ const mockCheckLicense = jest.fn();
20
+
21
+ // @working-theory/license is an external package boundary — stays mocked.
22
+ jest.mock("@working-theory/license", () => ({
23
+ checkLicense: (...args: unknown[]) => mockCheckLicense(...args),
24
+ }));
25
+
26
+ import { AdapterWriteError } from "@working-theory/content-adapters";
27
+
28
+ import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
29
+ import { contentRoutes } from "../index";
30
+
31
+ const fixture = setupContentFixture();
32
+
33
+ function proLicense() {
34
+ return {
35
+ tier: "pro",
36
+ plan: "pro",
37
+ features: [],
38
+ expiresAt: null,
39
+ revoked: false,
40
+ };
41
+ }
42
+
43
+ function freeLicense() {
44
+ return {
45
+ tier: "free",
46
+ plan: null,
47
+ features: [],
48
+ expiresAt: null,
49
+ revoked: false,
50
+ };
51
+ }
52
+
53
+ // Update body must match the slug from the URL.
54
+ const validBody = {
55
+ type: "HomePage",
56
+ slug: "home",
57
+ title: "Welcome (updated)",
58
+ description: "Updated description",
59
+ body: "Updated body content",
60
+ };
61
+
62
+ describe("PATCH /api/content/:type/:slug", () => {
63
+ let app: FastifyInstance;
64
+
65
+ beforeEach(async () => {
66
+ app = require("fastify")();
67
+ await app.register(contentRoutes);
68
+ jest.clearAllMocks();
69
+ mockCheckLicense.mockReset();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await app.close();
74
+ });
75
+
76
+ it("returns 200 with {page, version} on success", async () => {
77
+ mockCheckLicense.mockResolvedValue(proLicense());
78
+
79
+ // The fixture writes "home" in beforeEach; PATCH it with If-Match.
80
+ // First POST to get the version header, then PATCH.
81
+ // For simplicity: the real MDX adapter returns a version on create/update.
82
+ // We pass a version token that matches — the adapter uses content hash.
83
+ // Get current version first by creating it via POST:
84
+ const createRes = await app.inject({
85
+ method: "POST",
86
+ url: "/v1/content/HomePage",
87
+ payload: {
88
+ type: "HomePage",
89
+ slug: "update-target",
90
+ title: "Original",
91
+ description: "Original description",
92
+ body: "Original body",
93
+ },
94
+ });
95
+ expect(createRes.statusCode).toBe(201);
96
+ const { version } = JSON.parse(createRes.body);
97
+
98
+ const res = await app.inject({
99
+ method: "PATCH",
100
+ url: "/v1/content/HomePage/update-target",
101
+ headers: { "if-match": version },
102
+ payload: {
103
+ type: "HomePage",
104
+ slug: "update-target",
105
+ title: "Welcome (updated)",
106
+ description: "Updated description",
107
+ body: "Updated body",
108
+ },
109
+ });
110
+
111
+ expect(res.statusCode).toBe(200);
112
+ const body = JSON.parse(res.body);
113
+ expect(body).toHaveProperty("page");
114
+ expect(body).toHaveProperty("version");
115
+ expect(body.page).toMatchObject({ type: "HomePage", slug: "update-target" });
116
+ });
117
+
118
+ it("returns 402 payment_required on free tier", async () => {
119
+ mockCheckLicense.mockResolvedValue(freeLicense());
120
+
121
+ const res = await app.inject({
122
+ method: "PATCH",
123
+ url: "/v1/content/HomePage/home",
124
+ headers: { "if-match": "v1" },
125
+ payload: validBody,
126
+ });
127
+
128
+ expect(res.statusCode).toBe(402);
129
+ });
130
+
131
+ it("returns 402 payment_required when license is revoked even at pro tier", async () => {
132
+ mockCheckLicense.mockResolvedValue({
133
+ tier: "pro",
134
+ plan: "pro",
135
+ features: [],
136
+ expiresAt: null,
137
+ revoked: true,
138
+ });
139
+
140
+ const res = await app.inject({
141
+ method: "PATCH",
142
+ url: "/v1/content/HomePage/home",
143
+ headers: { "if-match": "v1" },
144
+ payload: validBody,
145
+ });
146
+
147
+ expect(res.statusCode).toBe(402);
148
+ expect(JSON.parse(res.body)).toMatchObject({ error: "payment_required" });
149
+ });
150
+
151
+ it("returns 409 conflict when If-Match header is missing", async () => {
152
+ mockCheckLicense.mockResolvedValue(proLicense());
153
+
154
+ const res = await app.inject({
155
+ method: "PATCH",
156
+ url: "/v1/content/HomePage/home",
157
+ payload: validBody,
158
+ });
159
+
160
+ expect(res.statusCode).toBe(409);
161
+ expect(JSON.parse(res.body)).toMatchObject({ error: "conflict" });
162
+ });
163
+
164
+ it("returns 409 conflict when If-Match is empty string", async () => {
165
+ mockCheckLicense.mockResolvedValue(proLicense());
166
+
167
+ const res = await app.inject({
168
+ method: "PATCH",
169
+ url: "/v1/content/HomePage/home",
170
+ headers: { "if-match": "" },
171
+ payload: validBody,
172
+ });
173
+
174
+ expect(res.statusCode).toBe(409);
175
+ expect(JSON.parse(res.body)).toMatchObject({ error: "conflict" });
176
+ });
177
+
178
+ it("returns 409 conflict with currentVersion echoed when adapter signals stale If-Match", async () => {
179
+ mockCheckLicense.mockResolvedValue(proLicense());
180
+
181
+ // Pass a stale version — the real adapter will reject it with a conflict.
182
+ const res = await app.inject({
183
+ method: "PATCH",
184
+ url: "/v1/content/HomePage/home",
185
+ headers: { "if-match": "stale-version-that-does-not-match" },
186
+ payload: validBody,
187
+ });
188
+
189
+ // The real adapter rejects a stale If-Match with 409 + currentVersion.
190
+ expect(res.statusCode).toBe(409);
191
+ const body = JSON.parse(res.body);
192
+ expect(body).toMatchObject({ error: "conflict" });
193
+ // currentVersion is echoed in details when the adapter provides it.
194
+ if (body.details) {
195
+ expect(body.details).toHaveProperty("currentVersion");
196
+ }
197
+ });
198
+
199
+ it("returns 404 not_found when adapter signals page missing", async () => {
200
+ mockCheckLicense.mockResolvedValue(proLicense());
201
+
202
+ const res = await app.inject({
203
+ method: "PATCH",
204
+ url: "/v1/content/HomePage/slug-that-does-not-exist",
205
+ headers: { "if-match": "any-version" },
206
+ payload: { ...validBody, slug: "slug-that-does-not-exist" },
207
+ });
208
+
209
+ expect(res.statusCode).toBe(404);
210
+ expect(JSON.parse(res.body)).toMatchObject({ error: "not_found" });
211
+ });
212
+
213
+ it("returns 400 validation_failed on bad body", async () => {
214
+ mockCheckLicense.mockResolvedValue(proLicense());
215
+
216
+ const res = await app.inject({
217
+ method: "PATCH",
218
+ url: "/v1/content/HomePage/home",
219
+ headers: { "if-match": "v1" },
220
+ payload: { type: "HomePage", slug: "home" }, // missing required fields
221
+ });
222
+
223
+ expect(res.statusCode).toBe(400);
224
+ expect(JSON.parse(res.body)).toMatchObject({ error: "validation_failed" });
225
+ });
226
+
227
+ it("returns 400 validation_failed on unknown :type", async () => {
228
+ mockCheckLicense.mockResolvedValue(proLicense());
229
+
230
+ const res = await app.inject({
231
+ method: "PATCH",
232
+ url: "/v1/content/Bogus/home",
233
+ headers: { "if-match": "v1" },
234
+ payload: validBody,
235
+ });
236
+
237
+ expect(res.statusCode).toBe(400);
238
+ });
239
+
240
+ it("returns 400 when path :type/:slug does not match body type/slug", async () => {
241
+ mockCheckLicense.mockResolvedValue(proLicense());
242
+
243
+ const res = await app.inject({
244
+ method: "PATCH",
245
+ url: "/v1/content/HomePage/home",
246
+ headers: { "if-match": "v1" },
247
+ payload: { ...validBody, slug: "different-slug" },
248
+ });
249
+
250
+ expect(res.statusCode).toBe(400);
251
+ });
252
+ });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * POST /v1/content/:type
3
+ *
4
+ * Creates a new page of `:type` from a JSON body matching `FrontmatterSchema`
5
+ * (from `@working-theory/content-adapters`, the SSOT for content shape) plus a non-empty
6
+ * `body` string. Paid-licensed deploys only — free tier returns 402 via the
7
+ * `requirePaidTier` preHandler from Stage 0.
8
+ *
9
+ * Conflict semantics: if a page with this slug already exists, returns 409
10
+ * with the existing page's `version` echoed in `details` so the caller can
11
+ * pivot to PATCH without a re-read. The conflict signal is delivered by the
12
+ * adapter via `AdapterWriteError("conflict", ..., { version })` and translated
13
+ * by the hoisted `mapAdapterError` (issue #398) — the route no longer
14
+ * pre-checks slug existence (closes the V2-H2 TOCTOU window between the
15
+ * read and the write).
16
+ *
17
+ * Errors all use the canonical `{error, message, details?}` shape from
18
+ * `apps/api/src/lib/errors.ts`.
19
+ */
20
+ import {
21
+ FrontmatterSchema,
22
+ SCHEMA_ORG_TYPES,
23
+ type SchemaOrgType,
24
+ } from "@working-theory/content-adapters";
25
+ import type { FastifyPluginAsync, FastifyReply } from "fastify";
26
+ import { z } from "zod";
27
+
28
+ import { getContentAdapter } from "../../lib/content-adapter.js";
29
+ import {
30
+ adapterErrorResponse,
31
+ validationError,
32
+ type ErrorBody,
33
+ } from "../../lib/errors.js";
34
+ import { requirePaidTier } from "../../lib/require-paid-tier.js";
35
+
36
+ import { SLUG_PATTERN } from "./slug.js";
37
+
38
+ const paramsSchema = z.object({
39
+ type: z.enum(SCHEMA_ORG_TYPES),
40
+ });
41
+
42
+ const writeBodySchema = FrontmatterSchema.extend({ body: z.string().min(1) });
43
+
44
+ function send(reply: FastifyReply, status: number, body: ErrorBody) {
45
+ return reply.status(status).send(body);
46
+ }
47
+
48
+ export const createPageRoute: FastifyPluginAsync = async (fastify) => {
49
+ fastify.post<{
50
+ Params: { type: string };
51
+ Body: unknown;
52
+ }>(
53
+ "/v1/content/:type",
54
+
55
+ { preHandler: requirePaidTier },
56
+ async (request, reply) => {
57
+ const params = paramsSchema.safeParse(request.params);
58
+ if (!params.success) {
59
+ const err = validationError("Invalid type", {
60
+ issues: params.error.issues,
61
+ });
62
+ return send(reply, err.status, err.body);
63
+ }
64
+
65
+ const parsed = writeBodySchema.safeParse(request.body);
66
+ if (!parsed.success) {
67
+ const err = validationError("Invalid request body", {
68
+ issues: parsed.error.issues,
69
+ });
70
+ return send(reply, err.status, err.body);
71
+ }
72
+
73
+ const { body: pageBody, ...page } = parsed.data;
74
+
75
+ if (!SLUG_PATTERN.test(page.slug)) {
76
+ const err = validationError("Invalid request body", {
77
+ issues: [
78
+ {
79
+ path: ["slug"],
80
+ message:
81
+ "slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/ (lowercase, digits, hyphens; no leading/trailing hyphen)",
82
+ },
83
+ ],
84
+ });
85
+ return send(reply, err.status, err.body);
86
+ }
87
+
88
+ if (page.type !== params.data.type) {
89
+ const err = validationError(
90
+ "Body `type` must match URL :type parameter",
91
+ { urlType: params.data.type, bodyType: page.type },
92
+ );
93
+ return send(reply, err.status, err.body);
94
+ }
95
+
96
+ const { type } = params.data;
97
+
98
+ try {
99
+ const adapter = getContentAdapter();
100
+ const result = await adapter.createPage(
101
+ type as SchemaOrgType,
102
+ page.slug,
103
+ { ...page, body: pageBody },
104
+ );
105
+ return reply.status(201).send(result);
106
+ } catch (error) {
107
+ const err = adapterErrorResponse(error, { type, slug: page.slug });
108
+ if (err) return send(reply, err.status, err.body);
109
+ request.log.error({ err: error }, "[Content] createPage failed");
110
+ return reply.status(500).send({ error: "Internal server error" });
111
+ }
112
+ },
113
+ );
114
+ };
@@ -0,0 +1,81 @@
1
+ /**
2
+ * DELETE /v1/content/:type/:slug
3
+ *
4
+ * Removes the page of `:type` at `:slug`. Paid-licensed deploys only.
5
+ *
6
+ * Optimistic locking is mandatory: the request MUST carry an `If-Match`
7
+ * header containing the `version` token from a prior read. Missing/empty →
8
+ * 409. Stale → 409 with `details.currentVersion` echoed.
9
+ *
10
+ * 404 when the page does not exist. Success responds 204 No Content.
11
+ *
12
+ * Adapter errors (`AdapterWriteError("not_found" | "conflict" | …)`) are
13
+ * translated by the hoisted `mapAdapterError` from `@working-theory/content-adapters`
14
+ * (issue #398) — single source of truth across the API + MCP surfaces.
15
+ */
16
+ import {
17
+ SCHEMA_ORG_TYPES,
18
+ type SchemaOrgType,
19
+ } from "@working-theory/content-adapters";
20
+ import type { FastifyPluginAsync, FastifyReply } from "fastify";
21
+ import { z } from "zod";
22
+
23
+ import { getContentAdapter } from "../../lib/content-adapter.js";
24
+ import {
25
+ adapterErrorResponse,
26
+ conflictError,
27
+ validationError,
28
+ type ErrorBody,
29
+ } from "../../lib/errors.js";
30
+ import { requirePaidTier } from "../../lib/require-paid-tier.js";
31
+
32
+ import { SLUG_PATTERN } from "./slug.js";
33
+
34
+ const paramsSchema = z.object({
35
+ type: z.enum(SCHEMA_ORG_TYPES),
36
+ slug: z.string().regex(SLUG_PATTERN),
37
+ });
38
+
39
+ function send(reply: FastifyReply, status: number, body: ErrorBody) {
40
+ return reply.status(status).send(body);
41
+ }
42
+
43
+ export const deletePageRoute: FastifyPluginAsync = async (fastify) => {
44
+ fastify.delete<{
45
+ Params: { type: string; slug: string };
46
+ }>(
47
+ "/v1/content/:type/:slug",
48
+
49
+ { preHandler: requirePaidTier },
50
+ async (request, reply) => {
51
+ const params = paramsSchema.safeParse(request.params);
52
+ if (!params.success) {
53
+ const err = validationError("Invalid type or slug", {
54
+ issues: params.error.issues,
55
+ });
56
+ return send(reply, err.status, err.body);
57
+ }
58
+
59
+ const ifMatch = request.headers["if-match"];
60
+ if (typeof ifMatch !== "string" || ifMatch.length === 0) {
61
+ const err = conflictError(
62
+ "Missing required If-Match header (version token)",
63
+ );
64
+ return send(reply, err.status, err.body);
65
+ }
66
+
67
+ const { type, slug } = params.data;
68
+
69
+ try {
70
+ const adapter = getContentAdapter();
71
+ await adapter.deletePage(type as SchemaOrgType, slug, { ifMatch });
72
+ return reply.status(204).send();
73
+ } catch (error) {
74
+ const err = adapterErrorResponse(error, { type, slug });
75
+ if (err) return send(reply, err.status, err.body);
76
+ request.log.error({ err: error }, "[Content] deletePage failed");
77
+ return reply.status(500).send({ error: "Internal server error" });
78
+ }
79
+ },
80
+ );
81
+ };
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GET /v1/content/:type/:slug
3
+ *
4
+ * Returns a single page with its resolved schema.org JSON-LD spread at the
5
+ * document root, plus the raw frontmatter under `page` for clients that need
6
+ * the unrendered shape.
7
+ *
8
+ * 404 on missing slug, 400 on unknown type.
9
+ *
10
+ * Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
11
+ * `readLocales()` allowlist; invalid or unknown locale silently falls back to
12
+ * the project's default locale (AC-i18n-07). The adapter applies the fallback
13
+ * chain (locale → default-locale → legacy non-prefixed layout).
14
+ */
15
+ import { SCHEMA_ORG_TYPES, type SchemaOrgType } from "@working-theory/content-adapters";
16
+ import { buildJsonLd, type ContentPageInput } from "@working-theory/schema";
17
+ import type { FastifyPluginAsync } from "fastify";
18
+ import { z } from "zod";
19
+
20
+ import { getContentAdapter } from "../../lib/content-adapter.js";
21
+ import { resolveLocale } from "../../lib/locales.js";
22
+
23
+ const paramsSchema = z.object({
24
+ type: z.enum(SCHEMA_ORG_TYPES),
25
+ slug: z.string().min(1),
26
+ });
27
+
28
+ const querySchema = z.object({
29
+ locale: z.string().optional(),
30
+ });
31
+
32
+ export const getPageRoute: FastifyPluginAsync = async (fastify) => {
33
+ fastify.get<{
34
+ Params: { type: string; slug: string };
35
+ Querystring: { locale?: string };
36
+ }>(
37
+ "/v1/content/:type/:slug",
38
+ async (request, reply) => {
39
+ const parsed = paramsSchema.safeParse(request.params);
40
+ if (!parsed.success) {
41
+ return reply.status(400).send({
42
+ error: "Invalid type or slug",
43
+ details: parsed.error.issues,
44
+ });
45
+ }
46
+
47
+ const queryParsed = querySchema.safeParse(request.query);
48
+ if (!queryParsed.success) {
49
+ return reply.status(400).send({
50
+ error: "Invalid query parameters",
51
+ details: queryParsed.error.issues,
52
+ });
53
+ }
54
+
55
+ const { type, slug } = parsed.data;
56
+ const locale = resolveLocale(queryParsed.data.locale);
57
+
58
+ try {
59
+ const adapter = getContentAdapter();
60
+ const page = await adapter.getPageBySlug(
61
+ type as SchemaOrgType,
62
+ slug,
63
+ locale,
64
+ );
65
+ if (!page) {
66
+ return reply.status(404).send({
67
+ error: "Page not found",
68
+ type,
69
+ slug,
70
+ });
71
+ }
72
+
73
+ const input: ContentPageInput = {
74
+ type: page.type,
75
+ slug: page.slug,
76
+ title: page.title,
77
+ description: page.description,
78
+ ...(page.publishedAt !== undefined && {
79
+ publishedAt: page.publishedAt,
80
+ }),
81
+ ...(page.updatedAt !== undefined && { updatedAt: page.updatedAt }),
82
+ ...(page.image !== undefined && { image: page.image }),
83
+ ...(page.schema !== undefined && { schema: page.schema }),
84
+ // ADR-0026: carry the page's typed sections so recipes compose entity
85
+ // JSON-LD from real section data (e.g. FAQPage.mainEntity from `faq`
86
+ // sections) rather than a hand-rolled `schema` override. The adapter
87
+ // now preserves `sections`; before, it was stripped at validation.
88
+ ...(page.sections !== undefined && { sections: page.sections }),
89
+ };
90
+ const jsonLd = buildJsonLd(input);
91
+ return reply.send({ ...jsonLd, page });
92
+ } catch (error) {
93
+ request.log.error({ err: error }, "[Content] getPage failed");
94
+ return reply.status(500).send({ error: "Internal server error" });
95
+ }
96
+ },
97
+ );
98
+ };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Public content API.
3
+ *
4
+ * Read endpoints, all GET, all JSON-LD-shaped at the document root:
5
+ * GET /v1/content/types — supported page types
6
+ * GET /v1/content/:type — paginated list of pages
7
+ * GET /v1/content/:type/:slug — single page with resolved JSON-LD
8
+ *
9
+ * Write endpoints (D14a, Pro-tier gated via `requirePaidTier` preHandler):
10
+ * POST /v1/content/:type — create a new page
11
+ * PATCH /v1/content/:type/:slug — update; mandatory `If-Match`
12
+ * DELETE /v1/content/:type/:slug — delete; mandatory `If-Match`
13
+ *
14
+ * Rate-limit, CORS, and the OpenAPI spec are wired at server level.
15
+ */
16
+ import type { FastifyPluginAsync } from "fastify";
17
+
18
+ import { createPageRoute } from "./create.js";
19
+ import { deletePageRoute } from "./delete.js";
20
+ import { getPageRoute } from "./get-page.js";
21
+ import { listPagesRoute } from "./list-pages.js";
22
+ import { listTypesRoute } from "./list-types.js";
23
+ import { updatePageRoute } from "./update.js";
24
+
25
+ export const contentRoutes: FastifyPluginAsync = async (fastify) => {
26
+ await fastify.register(listTypesRoute);
27
+ await fastify.register(listPagesRoute);
28
+ await fastify.register(getPageRoute);
29
+ await fastify.register(createPageRoute);
30
+ await fastify.register(updatePageRoute);
31
+ await fastify.register(deletePageRoute);
32
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * GET /v1/content/:type
3
+ *
4
+ * Paginated list of pages for a given schema.org type. Defaults to page 1,
5
+ * pageSize 20; pageSize is capped at 100 to keep response payloads bounded.
6
+ *
7
+ * Response envelope is an `ItemList` JSON-LD document carrying the page
8
+ * frontmatter under `items` and pagination metadata alongside.
9
+ *
10
+ * Phase 3 S3: optional `?locale=<locale>` query parameter. Validated against
11
+ * `readLocales()` allowlist; invalid or unknown locale silently falls back to
12
+ * the project's default locale (AC-i18n-07).
13
+ */
14
+ import { SCHEMA_ORG_TYPES, type SchemaOrgType } from "@working-theory/content-adapters";
15
+ import type { FastifyPluginAsync } from "fastify";
16
+ import { z } from "zod";
17
+
18
+ import { getContentAdapter } from "../../lib/content-adapter.js";
19
+ import { resolveLocale } from "../../lib/locales.js";
20
+
21
+ const paramsSchema = z.object({
22
+ type: z.enum(SCHEMA_ORG_TYPES),
23
+ });
24
+
25
+ const PAGE_SIZE_MAX = 100;
26
+
27
+ const querySchema = z.object({
28
+ page: z.coerce.number().int().min(1).default(1),
29
+ pageSize: z.coerce
30
+ .number()
31
+ .int()
32
+ .min(1)
33
+ .default(20)
34
+ .transform((v) => Math.min(v, PAGE_SIZE_MAX)),
35
+ locale: z.string().optional(),
36
+ });
37
+
38
+ export const listPagesRoute: FastifyPluginAsync = async (fastify) => {
39
+ fastify.get<{
40
+ Params: { type: string };
41
+ Querystring: { page?: string; pageSize?: string; locale?: string };
42
+ }>("/v1/content/:type", async (request, reply) => {
43
+ const params = paramsSchema.safeParse(request.params);
44
+ if (!params.success) {
45
+ return reply.status(400).send({
46
+ error: "Invalid type",
47
+ details: params.error.issues,
48
+ });
49
+ }
50
+
51
+ const query = querySchema.safeParse(request.query);
52
+ if (!query.success) {
53
+ return reply.status(400).send({
54
+ error: "Invalid query parameters",
55
+ details: query.error.issues,
56
+ });
57
+ }
58
+
59
+ const { type } = params.data;
60
+ const { page, pageSize } = query.data;
61
+ const locale = resolveLocale(query.data.locale);
62
+
63
+ try {
64
+ const adapter = getContentAdapter();
65
+ const all = await adapter.listPages(type as SchemaOrgType, locale);
66
+ const totalItems = all.length;
67
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
68
+ const start = (page - 1) * pageSize;
69
+ const items = all.slice(start, start + pageSize);
70
+
71
+ return reply.send({
72
+ "@context": "https://schema.org",
73
+ "@type": "ItemList",
74
+ type,
75
+ items,
76
+ pagination: { page, pageSize, totalItems, totalPages },
77
+ });
78
+ } catch (error) {
79
+ request.log.error({ err: error }, "[Content] listPages failed");
80
+ return reply.status(500).send({ error: "Internal server error" });
81
+ }
82
+ });
83
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * GET /v1/content/types
3
+ *
4
+ * Returns the canonical list of supported schema.org page types. Wrapped in
5
+ * an `ItemList` JSON-LD envelope so the response carries `@context` / `@type`
6
+ * at the document root, consistent with the other content endpoints.
7
+ */
8
+ import { SCHEMA_ORG_TYPES } from "@working-theory/content-adapters";
9
+ import type { FastifyPluginAsync } from "fastify";
10
+
11
+ export const listTypesRoute: FastifyPluginAsync = async (fastify) => {
12
+ fastify.get("/v1/content/types", async (_request, reply) => {
13
+ return reply.send({
14
+ "@context": "https://schema.org",
15
+ "@type": "ItemList",
16
+ types: [...SCHEMA_ORG_TYPES],
17
+ });
18
+ });
19
+ };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Rate-limit configuration for the public content API.
3
+ *
4
+ * v1: 60 req/min/IP for unauthenticated callers. Future: when an auth context
5
+ * lands on the request (e.g. an `auth` claim from the API-token middleware),
6
+ * the limit can be raised by reading that context inside `keyGenerator` /
7
+ * `max`. Until then we apply the unauth ceiling globally — agents reading the
8
+ * public API generally don't authenticate, and authenticated agents have
9
+ * their own ceiling configurable per-token.
10
+ */
11
+ export const CONTENT_RATE_LIMIT_UNAUTH = 60;
12
+ export const CONTENT_RATE_LIMIT_AUTH = 600;
13
+ export const CONTENT_RATE_LIMIT_WINDOW = "1 minute";