create-ailk 0.5.0 → 0.6.1

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 (329) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/cli.js +54 -5
  5. package/dist/copy.d.ts +14 -0
  6. package/dist/copy.js +38 -1
  7. package/dist/lib/api-boot-assertion.d.ts +46 -0
  8. package/dist/lib/api-boot-assertion.js +70 -0
  9. package/dist/lib/extract-module-bundle.d.ts +20 -1
  10. package/dist/lib/extract-module-bundle.js +64 -5
  11. package/dist/lib/import-graph.d.ts +29 -0
  12. package/dist/lib/import-graph.js +140 -0
  13. package/dist/lib/module-license-gate.d.ts +6 -1
  14. package/dist/lib/module-license-gate.js +6 -1
  15. package/dist/lib/pro-callout.js +1 -1
  16. package/dist/module-architecture.d.ts +1 -0
  17. package/dist/module-architecture.js +253 -127
  18. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  19. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  20. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  21. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  22. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  23. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  24. package/dist/programmatic.js +14 -10
  25. package/dist/surfaces.js +105 -7
  26. package/dist/template-manifest.d.ts +33 -0
  27. package/dist/template-manifest.js +99 -16
  28. package/dist/template-names.d.ts +1 -1
  29. package/dist/template-names.js +1 -0
  30. package/package.json +3 -2
  31. package/site-templates/marketing-site/DESIGN.md +148 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  34. package/site-templates/marketing-site/ailk-template.json +1 -1
  35. package/site-templates/marketing-site-free/DESIGN.md +148 -0
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  38. package/site-templates/minimalism-site/DESIGN.md +148 -0
  39. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  41. package/site-templates/minimalism-site/ailk-template.json +2 -1
  42. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  43. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  44. package/site-templates/saas-product/DESIGN.md +148 -0
  45. package/site-templates/simple-service/DESIGN.md +148 -0
  46. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  47. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  48. package/site-templates/simple-service/ailk-template.json +10 -0
  49. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  50. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  51. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  52. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  53. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  54. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  55. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  56. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  57. package/site-templates/simple-service/provenance.json +4 -0
  58. package/templates/.env.example +72 -7
  59. package/templates/CLAUDE.md +2 -3
  60. package/templates/DESIGN.md +155 -0
  61. package/templates/README.md +42 -8
  62. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  63. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  64. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  65. package/templates/apps/api/.env.example +13 -0
  66. package/templates/apps/api/CLAUDE.md +26 -2
  67. package/templates/apps/api/jest.config.cjs +3 -0
  68. package/templates/apps/api/package.json +1 -0
  69. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  70. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  71. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  72. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  73. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  74. package/templates/apps/api/src/config/index.ts +62 -0
  75. package/templates/apps/api/src/config/modules.ts +7 -3
  76. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  77. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  78. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  79. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  81. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  82. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  83. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  84. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  85. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  86. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  87. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  88. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  89. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  90. package/templates/apps/api/src/lib/site.ts +208 -9
  91. package/templates/apps/api/src/lib/stripe.ts +66 -0
  92. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  93. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  94. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  95. package/templates/apps/api/src/openapi/spec.ts +543 -2
  96. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  97. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  98. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  99. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  100. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  101. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  102. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  103. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  104. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  105. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  106. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  107. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  108. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  109. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  110. package/templates/apps/api/src/routes/content/index.ts +13 -73
  111. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  112. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  113. package/templates/apps/api/src/routes/content/search.ts +129 -0
  114. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  115. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  116. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  120. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  121. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  122. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  123. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  124. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  125. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  126. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  127. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  128. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  129. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  130. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  131. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  132. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  133. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  134. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  135. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  136. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  137. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  138. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  139. package/templates/apps/api/src/server.ts +439 -49
  140. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  141. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  142. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  143. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  144. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  145. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  146. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  147. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  148. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  149. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  150. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  151. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  152. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  153. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  154. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  155. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  156. package/templates/apps/api/src/services/audit-log.ts +668 -0
  157. package/templates/apps/api/src/services/consent.ts +18 -0
  158. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  159. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  160. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  161. package/templates/apps/api/src/services/entitlements.ts +80 -0
  162. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  163. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  164. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  165. package/templates/apps/api/src/services/listing-config.ts +27 -9
  166. package/templates/apps/api/src/services/project-listings.ts +201 -6
  167. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  168. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  169. package/templates/apps/api/src/services/revalidation.ts +173 -0
  170. package/templates/apps/api/src/services/verified-email.ts +71 -0
  171. package/templates/apps/api/src/vercel-handler.ts +75 -35
  172. package/templates/apps/mcp/.env.example +17 -0
  173. package/templates/apps/mcp/CLAUDE.md +57 -24
  174. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  175. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  176. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  177. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  178. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  179. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  180. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  181. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  182. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  183. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  184. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  185. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  186. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  187. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  188. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  189. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  190. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  191. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  192. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  193. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  194. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  195. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  196. package/templates/apps/mcp/jest.config.cjs +13 -0
  197. package/templates/apps/mcp/package.json +2 -0
  198. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  199. package/templates/apps/mcp/src/config/modules.ts +10 -5
  200. package/templates/apps/mcp/src/http-transport.ts +102 -29
  201. package/templates/apps/mcp/src/index.ts +73 -11
  202. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  203. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  204. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  205. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  206. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  207. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  208. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  209. package/templates/apps/mcp/src/server.ts +59 -35
  210. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  211. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  212. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  213. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  214. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  215. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  216. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  217. package/templates/apps/mcp/src/tools/index.ts +6 -4
  218. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  219. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  220. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  221. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  222. package/templates/apps/web/.env.example +71 -5
  223. package/templates/apps/web/CLAUDE.md +44 -0
  224. package/templates/apps/web/PRODUCT.md +86 -0
  225. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  226. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  227. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  228. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  229. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  230. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  231. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  232. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  233. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  234. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  235. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  236. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  237. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  238. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  239. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  240. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  241. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  242. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  243. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  244. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  245. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  246. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  247. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  248. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  249. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  250. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  251. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  252. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  253. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  254. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  255. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  256. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  257. package/templates/apps/web/auth.ts +407 -8
  258. package/templates/apps/web/jest.config.cjs +8 -4
  259. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  260. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  261. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  262. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  263. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  264. package/templates/apps/web/lib/auth-email.ts +318 -0
  265. package/templates/apps/web/lib/auth-env.ts +85 -0
  266. package/templates/apps/web/lib/auth-routes.ts +55 -0
  267. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  268. package/templates/apps/web/lib/site-brand.tsx +4 -8
  269. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  270. package/templates/apps/web/messages/en.json +187 -0
  271. package/templates/apps/web/middleware.ts +1 -1
  272. package/templates/apps/web/package.json +2 -1
  273. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  274. package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
  275. package/templates/content/_site.mdx +18 -3
  276. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  277. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  278. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  279. package/templates/database/CHANGELOG.md +149 -0
  280. package/templates/database/README.md +10 -3
  281. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  282. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  283. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  284. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  285. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  286. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  287. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  288. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  289. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  290. package/templates/database/__tests__/tenancy.test.ts +141 -0
  291. package/templates/database/config/schema.prisma +79 -2
  292. package/templates/database/content/schema.prisma +87 -11
  293. package/templates/database/inbox/schema.prisma +452 -9
  294. package/templates/database/jest.config.cjs +5 -3
  295. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  296. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  297. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  298. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  299. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  300. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  301. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  302. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  303. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  304. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  305. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  306. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  307. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  308. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  309. package/templates/database/package.json +3 -2
  310. package/templates/database/tenancy.ts +86 -1
  311. package/templates/database/tsconfig.json +1 -0
  312. package/versions.json +34 -34
  313. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  314. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  315. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  316. package/templates/apps/api/src/routes/content/create.ts +0 -115
  317. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  318. package/templates/apps/api/src/routes/content/update.ts +0 -112
  319. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  320. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  321. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  322. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  323. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  324. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  325. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  326. package/templates/apps/web/public/logo-dark.svg +0 -21
  327. package/templates/apps/web/public/logo-light.svg +0 -21
  328. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  329. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -9,6 +9,10 @@
9
9
  import type { FastifyInstance } from "fastify";
10
10
 
11
11
  import { setupContentFixture } from "../../../__tests__/factories/content-fixture";
12
+ import {
13
+ getContentAdapter,
14
+ type MdxContentAdapter,
15
+ } from "../../../lib/content-adapter.js";
12
16
  import { contentRoutes } from "../index";
13
17
 
14
18
  const fixture = setupContentFixture();
@@ -71,6 +75,30 @@ describe("GET /api/content/:type/:slug", () => {
71
75
  expect(res.statusCode).toBe(400);
72
76
  });
73
77
 
78
+ // #5927 — the read-path slug tightens from `z.string().min(1)` to
79
+ // SLUG_PATTERN, the same boundary create/update/delete already enforce on
80
+ // their own slug params. `paramsSchema.safeParse` runs before any adapter
81
+ // is resolved, so this 400 is unconditionally before any content query.
82
+ it("returns 400 on a traversal-shaped slug, before any adapter call", async () => {
83
+ // The slash is percent-encoded so it stays part of one path segment
84
+ // (`:slug`) rather than splitting into a second one — a literal `../`
85
+ // would fail routing entirely, and a bare `..` dot-segment-normalizes
86
+ // away before find-my-way ever sees it (WHATWG URL §4.7 recognises both
87
+ // "." and ".." — and their sole percent-encoded variants — as
88
+ // normalize-away segments, RFC 3986 §5.2's algorithm). This encoded form
89
+ // survives normalization intact and decodes to "../etc/passwd" only once
90
+ // find-my-way populates `request.params.slug`, which is what the
91
+ // paramsSchema regex actually sees.
92
+ const res = await app.inject({
93
+ method: "GET",
94
+ url: "/v1/content/HomePage/..%2Fetc%2Fpasswd",
95
+ });
96
+
97
+ expect(res.statusCode).toBe(400);
98
+ const body = JSON.parse(res.body);
99
+ expect(body.error).toBe("Invalid type or slug");
100
+ });
101
+
74
102
  it("includes publishedAt in ContentPageInput when page has publishedAt", async () => {
75
103
  // Cover the `publishedAt !== undefined` branch.
76
104
  await fixture.writeFixturePage(
@@ -138,4 +166,530 @@ describe("GET /api/content/:type/:slug", () => {
138
166
  expect(body["@type"]).toBe("WebPage");
139
167
  expect(body.mainEntity["@type"]).toBe("SoftwareApplication");
140
168
  });
169
+
170
+ /**
171
+ * D3 — API↔MCP parity evidence for #6030's AC-1 ("both ends"). `apps/api`
172
+ * cannot import `apps/mcp` (the hard import boundary), so the shared seam
173
+ * is asserted instead: the route must resolve through the SAME
174
+ * `ContentAdapter.getPageSource(type, slug, locale)` call the MCP tool
175
+ * builds, for BlogPosting and DocsDetail — the two types this issue
176
+ * admits. `apps/mcp/__tests__/read_content.test.ts`'s "MDX leg" describe
177
+ * pins the other half, the same shape `search.test.ts`'s D6 section
178
+ * established for #5831. `get-page.ts` itself is unedited (N3) — it
179
+ * already accepted both types via `SCHEMA_ORG_TYPES` before this issue.
180
+ */
181
+ describe("parity with the adapter the MCP tool calls (D3)", () => {
182
+ it.each(["BlogPosting", "DocsDetail"] as const)(
183
+ "GET /v1/content/%s/:slug?include=body deep-equals adapter.getPageSource",
184
+ async (type) => {
185
+ const slug = "launch-post";
186
+ await fixture.writeFixturePage(
187
+ type,
188
+ slug,
189
+ {
190
+ type,
191
+ slug,
192
+ title: `${type} ${slug} title`,
193
+ description: `${type} ${slug} description`,
194
+ },
195
+ "# Written body\n\nWith a second paragraph.",
196
+ );
197
+
198
+ const res = await app.inject({
199
+ method: "GET",
200
+ url: `/v1/content/${type}/${slug}?include=body`,
201
+ });
202
+ expect(res.statusCode).toBe(200);
203
+
204
+ const adapter = getContentAdapter() as MdxContentAdapter;
205
+ const expected = await adapter.getPageSource(type, slug, "en");
206
+ expect(expected).not.toBeNull();
207
+
208
+ const body = JSON.parse(res.body);
209
+ expect(body.page).toMatchObject(expected!.frontmatter);
210
+ expect(body.body).toBe(expected!.body);
211
+ },
212
+ );
213
+ });
214
+ });
215
+
216
+ // ─── #5852 — the database path: which workspace a public read serves ────────
217
+ //
218
+ // Appended, so every MDX case above is untouched (AC-4). Database-free: the
219
+ // module-level Prisma singleton is faked via the repo's manual-mock convention,
220
+ // and `jest.mock` is hoisted above the imports by ts-jest regardless of where
221
+ // it is written. The MDX cases never load `lib/prisma.js` — the resolver's
222
+ // import is deferred behind the `CONTENT_ADAPTER=database` branch — so the mock
223
+ // is inert for them.
224
+
225
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
226
+ jest.mock("../../../lib/prisma.js");
227
+
228
+ import {
229
+ DEFAULT_ORG_ID,
230
+ DEFAULT_WORKSPACE_ID,
231
+ } from "@working-theory/database/tenancy";
232
+
233
+ import { prisma } from "../../../lib/prisma.js";
234
+
235
+ const PUBLISHED_VERSION_ID = "ver_published";
236
+
237
+ /** The artifact row a resolved, published `probe` page reads from. */
238
+ const PUBLISHED_ARTIFACT = {
239
+ id: "art_probe",
240
+ workspaceId: DEFAULT_WORKSPACE_ID,
241
+ contentType: "text/page",
242
+ locale: "en",
243
+ slug: "probe",
244
+ title: "Probe",
245
+ publishedVersionId: PUBLISHED_VERSION_ID,
246
+ };
247
+
248
+ /** The published version's `text/page` blob. */
249
+ const PUBLISHED_VERSION = {
250
+ id: PUBLISHED_VERSION_ID,
251
+ artifactId: PUBLISHED_ARTIFACT.id,
252
+ workspaceId: DEFAULT_WORKSPACE_ID,
253
+ isCurrent: false,
254
+ content: {
255
+ type: "HomePage",
256
+ slug: "probe",
257
+ title: "Probe",
258
+ description: "The published revision of the probe page.",
259
+ body: "Published body.",
260
+ },
261
+ };
262
+
263
+ /** A one-workspace deployment seeded with the well-known default. */
264
+ function seedOneWorkspaceDeployment(): void {
265
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
266
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
267
+ id: DEFAULT_WORKSPACE_ID,
268
+ organizationId: DEFAULT_ORG_ID,
269
+ });
270
+ }
271
+
272
+ describe("GET /v1/content/:type/:slug under CONTENT_ADAPTER=database (#5852)", () => {
273
+ let app: FastifyInstance;
274
+
275
+ beforeEach(async () => {
276
+ jest.clearAllMocks();
277
+ process.env.CONTENT_ADAPTER = "database";
278
+ app = require("fastify")();
279
+ await app.register(contentRoutes);
280
+ });
281
+
282
+ afterEach(async () => {
283
+ delete process.env.CONTENT_ADAPTER;
284
+ await app.close();
285
+ });
286
+
287
+ // AC-1 — the seeded default workspace's PUBLISHED revision, with no workspace
288
+ // id anywhere in the URL (AC-WCE-06, read half).
289
+ it("serves the default workspace's published revision, with no workspace id in the URL", async () => {
290
+ seedOneWorkspaceDeployment();
291
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue(
292
+ PUBLISHED_ARTIFACT,
293
+ );
294
+ (prisma.artifactVersion.findFirst as jest.Mock).mockResolvedValue(
295
+ PUBLISHED_VERSION,
296
+ );
297
+
298
+ const url = "/v1/content/HomePage/probe";
299
+ const res = await app.inject({ method: "GET", url });
300
+
301
+ expect(res.statusCode).toBe(200);
302
+ expect(JSON.parse(res.body).page.slug).toBe("probe");
303
+ // The tenant is resolved server-side; it is never addressable from the wire.
304
+ expect(url).not.toMatch(/wksp_/);
305
+ expect(prisma.artifact.findFirst).toHaveBeenCalledWith(
306
+ expect.objectContaining({
307
+ where: expect.objectContaining({ workspaceId: DEFAULT_WORKSPACE_ID }),
308
+ }),
309
+ );
310
+ });
311
+
312
+ // AC-2 — a draft with no published pointer is not public (the read-side
313
+ // invariant of AC-WCE-09). The draft tip is never even fetched.
314
+ it("404s a page whose artifact has a draft but no publishedVersionId", async () => {
315
+ seedOneWorkspaceDeployment();
316
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue({
317
+ ...PUBLISHED_ARTIFACT,
318
+ publishedVersionId: null,
319
+ });
320
+ // The draft tip EXISTS and would parse — the read must still not reach it.
321
+ (prisma.artifactVersion.findFirst as jest.Mock).mockResolvedValue({
322
+ ...PUBLISHED_VERSION,
323
+ id: "ver_draft",
324
+ isCurrent: true,
325
+ content: { ...PUBLISHED_VERSION.content, title: "Unpublished draft" },
326
+ });
327
+
328
+ const res = await app.inject({
329
+ method: "GET",
330
+ url: "/v1/content/HomePage/probe",
331
+ });
332
+
333
+ expect(res.statusCode).toBe(404);
334
+ expect(prisma.artifactVersion.findFirst).not.toHaveBeenCalled();
335
+ });
336
+
337
+ // AC-3 — enumeration: a foreign workspace's slug, a slug published nowhere,
338
+ // and both ambiguity arms are the SAME 404 bytes for the same request
339
+ // (AC-WCE-05), and the ambiguity arms emit theirs before any content query.
340
+ it("answers a foreign slug, a missing slug and both ambiguity arms with identical 404 bodies", async () => {
341
+ // (a) resolved deployment; `probe` is published only in a FOREIGN
342
+ // workspace, so the workspace-bound query cannot see it.
343
+ seedOneWorkspaceDeployment();
344
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue(null);
345
+ const foreign = await app.inject({
346
+ method: "GET",
347
+ url: "/v1/content/HomePage/probe",
348
+ });
349
+ expect(foreign.statusCode).toBe(404);
350
+ // What makes this a FOREIGN-workspace test rather than a mock returning
351
+ // null on request: the query is bound to the resolved workspace.
352
+ expect(prisma.artifact.findFirst).toHaveBeenCalledWith(
353
+ expect.objectContaining({
354
+ where: expect.objectContaining({ workspaceId: DEFAULT_WORKSPACE_ID }),
355
+ }),
356
+ );
357
+
358
+ // (b) resolved deployment; `probe` is published nowhere at all.
359
+ jest.clearAllMocks();
360
+ seedOneWorkspaceDeployment();
361
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue(null);
362
+ const missing = await app.inject({
363
+ method: "GET",
364
+ url: "/v1/content/HomePage/probe",
365
+ });
366
+ expect(missing.statusCode).toBe(404);
367
+
368
+ // (c) two workspaces — ambiguous, so no site resolves.
369
+ jest.clearAllMocks();
370
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(2);
371
+ const ambiguous = await app.inject({
372
+ method: "GET",
373
+ url: "/v1/content/HomePage/probe",
374
+ });
375
+ expect(ambiguous.statusCode).toBe(404);
376
+ expect(prisma.artifact.findFirst).not.toHaveBeenCalled();
377
+
378
+ // (d) no workspace at all — the same refusal.
379
+ jest.clearAllMocks();
380
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(0);
381
+ const empty = await app.inject({
382
+ method: "GET",
383
+ url: "/v1/content/HomePage/probe",
384
+ });
385
+ expect(empty.statusCode).toBe(404);
386
+ expect(prisma.artifact.findFirst).not.toHaveBeenCalled();
387
+
388
+ // The four bodies are byte-identical: a resolution failure and a missing
389
+ // page are indistinguishable to an unauthenticated caller.
390
+ expect(missing.body).toBe(foreign.body);
391
+ expect(ambiguous.body).toBe(foreign.body);
392
+ expect(empty.body).toBe(foreign.body);
393
+ });
394
+ });
395
+
396
+ // ─── #5890 — the published body over HTTP: an opt-in `?include=body` ────────
397
+ //
398
+ // Appended again, so every case above — the MDX cases (#507) and the database
399
+ // cases (#5852) — stays byte-identical. The inline snapshot below was recorded
400
+ // against the PRE-change handler and must survive the change unmodified; that,
401
+ // not a hand-copied literal, is the byte-identity evidence AC-1 asks for.
402
+ //
403
+ // The body-less-adapter cases use the real PROJECTION adapter rather than a
404
+ // module spy: it satisfies `ContentAdapter` and has no `getPageSource`, so the
405
+ // route's capability branch is exercised through the real resolver — the file
406
+ // header's "no internal module mocks" (#507) holds for these cases too.
407
+
408
+ import { mkdtempSync, writeFileSync } from "node:fs";
409
+ import { tmpdir } from "node:os";
410
+ import { join } from "node:path";
411
+
412
+ import * as contentAdapterModule from "../../../lib/content-adapter.js";
413
+ import { resetContentAdapter } from "../../../lib/content-adapter.js";
414
+
415
+ /** A projection manifest carrying the one page these cases read. */
416
+ function withProjectionAdapter(): void {
417
+ const manifestPath = join(
418
+ mkdtempSync(join(tmpdir(), "ailk-api-5890-")),
419
+ "manifest.json",
420
+ );
421
+ writeFileSync(
422
+ manifestPath,
423
+ JSON.stringify({
424
+ HomePage: [
425
+ {
426
+ type: "HomePage",
427
+ slug: "home",
428
+ title: "Projection home",
429
+ description: "Served by an adapter with no getPageSource.",
430
+ },
431
+ ],
432
+ }),
433
+ );
434
+ process.env.CONTENT_ADAPTER = "projection";
435
+ process.env.MANIFEST_PATH = manifestPath;
436
+ resetContentAdapter();
437
+ }
438
+
439
+ describe("GET /v1/content/:type/:slug?include=body (#5890)", () => {
440
+ let app: FastifyInstance;
441
+
442
+ beforeEach(async () => {
443
+ app = require("fastify")();
444
+ await app.register(contentRoutes);
445
+ });
446
+
447
+ afterEach(async () => {
448
+ jest.restoreAllMocks();
449
+ delete process.env.CONTENT_ADAPTER;
450
+ delete process.env.MANIFEST_PATH;
451
+ resetContentAdapter();
452
+ await app.close();
453
+ });
454
+
455
+ // AC-1, the floor half — the unparameterised read is unchanged to the byte.
456
+ it("serves a response byte-identical to the pre-change handler when the parameter is absent", async () => {
457
+ const res = await app.inject({
458
+ method: "GET",
459
+ url: "/v1/content/HomePage/home",
460
+ });
461
+
462
+ expect(res.statusCode).toBe(200);
463
+ expect(res.body).toMatchInlineSnapshot(
464
+ `"{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"HomePage home title","description":"HomePage home description"},{"@context":"https://schema.org","@type":"Organization","name":"HomePage home title"},{"@context":"https://schema.org","@type":"WebSite","name":"HomePage home title","inLanguage":"en"}],"page":{"type":"HomePage","slug":"home","title":"HomePage home title","description":"HomePage home description"}}"`,
465
+ );
466
+ expect(JSON.parse(res.body)).not.toHaveProperty("body");
467
+ });
468
+
469
+ // AC-1, the additive half — the raw MDX body, beside `page`, on request.
470
+ it("adds the raw published MDX body beside `page` when asked", async () => {
471
+ await fixture.writeFixturePage(
472
+ "HomePage",
473
+ "home",
474
+ {
475
+ type: "HomePage",
476
+ slug: "home",
477
+ title: "HomePage home title",
478
+ description: "HomePage home description",
479
+ },
480
+ "# Written body\n\nWith a second paragraph.",
481
+ );
482
+
483
+ const res = await app.inject({
484
+ method: "GET",
485
+ url: "/v1/content/HomePage/home?include=body",
486
+ });
487
+
488
+ expect(res.statusCode).toBe(200);
489
+ const body = JSON.parse(res.body);
490
+ // RAW, not trimmed: the body is handed to `next-mdx-remote` verbatim, so
491
+ // the route must not normalise whitespace the author wrote.
492
+ expect(body.body).toBe("\n# Written body\n\nWith a second paragraph.\n");
493
+ // Additive: everything the unparameterised read serves is still served.
494
+ expect(body.page).toMatchObject({ type: "HomePage", slug: "home" });
495
+ expect(body["@context"]).toBe("https://schema.org");
496
+ });
497
+
498
+ // The query idiom `list-pages.ts` uses: an unrecognised value is a 400 with
499
+ // the zod issues under `details`, never a silently-ignored parameter.
500
+ it("400s an unrecognised include value", async () => {
501
+ const res = await app.inject({
502
+ method: "GET",
503
+ url: "/v1/content/HomePage/home?include=other",
504
+ });
505
+
506
+ expect(res.statusCode).toBe(400);
507
+ const body = JSON.parse(res.body);
508
+ expect(body.error).toBe("Invalid query parameters");
509
+ expect(JSON.stringify(body.details)).toContain("include");
510
+ });
511
+
512
+ // AC-2's refusal arm — a body-less adapter answers 400 naming the parameter,
513
+ // never a 500 from `getPageSource is not a function`, and never after having
514
+ // read content it then throws away.
515
+ it("400s naming `include` when the resolved adapter has no getPageSource, before any content read", async () => {
516
+ withProjectionAdapter();
517
+ // The adapter is REAL — a projection adapter genuinely has no
518
+ // `getPageSource`. The module spy only PINS the instance (the resolver
519
+ // hands out a fresh object per call), so the read spy below observes the
520
+ // object the route actually uses instead of a discarded twin. Nothing
521
+ // about the adapter's behaviour is faked.
522
+ const real = contentAdapterModule.getContentAdapter();
523
+ const read = jest.spyOn(real, "getPageBySlug");
524
+ jest
525
+ .spyOn(contentAdapterModule, "getContentAdapter")
526
+ .mockReturnValue(real);
527
+
528
+ const res = await app.inject({
529
+ method: "GET",
530
+ url: "/v1/content/HomePage/home?include=body",
531
+ });
532
+
533
+ expect(res.statusCode).toBe(400);
534
+ const body = JSON.parse(res.body);
535
+ expect(body.error).toBe("Invalid query parameters");
536
+ expect(body.details).toEqual([
537
+ {
538
+ path: ["include"],
539
+ message: "body is not available from this content adapter",
540
+ },
541
+ ]);
542
+ // The refusal precedes the lookup: nothing was read to be discarded.
543
+ expect(read).not.toHaveBeenCalled();
544
+ });
545
+
546
+ // The same adapter still serves the unparameterised read unchanged — the
547
+ // capability gate is scoped to the parameter, not to the route.
548
+ it("leaves the unparameterised read working on a body-less adapter", async () => {
549
+ withProjectionAdapter();
550
+
551
+ const res = await app.inject({
552
+ method: "GET",
553
+ url: "/v1/content/HomePage/home",
554
+ });
555
+
556
+ expect(res.statusCode).toBe(200);
557
+ expect(JSON.parse(res.body).page.title).toBe("Projection home");
558
+ });
559
+
560
+ // #5927 — the fix's whole point: `include=body` performs exactly ONE
561
+ // adapter read, so `page` and `body` can never straddle two revisions. A
562
+ // mocked adapter whose two methods disagree proves it: if the route still
563
+ // called `getPageBySlug` as well as `getPageSource`, `page` would read the
564
+ // revision `getPageBySlug` returns while `body` read the (different)
565
+ // revision `getPageSource` returns — the mixed response this test forbids.
566
+ it("performs a single adapter read for include=body, so page and body can never straddle two revisions", async () => {
567
+ const staleFrontmatter = {
568
+ type: "HomePage" as const,
569
+ slug: "probe",
570
+ title: "Stale — the revision a second, independent read would see",
571
+ description: "Would only be returned by a getPageBySlug call.",
572
+ };
573
+ const published = {
574
+ frontmatter: {
575
+ type: "HomePage" as const,
576
+ slug: "probe",
577
+ title: "Published — the revision the single read actually returns",
578
+ description: "Returned by the one getPageSource call.",
579
+ },
580
+ body: "Published body.",
581
+ };
582
+ const getPageBySlug = jest.fn().mockResolvedValue(staleFrontmatter);
583
+ const getPageSource = jest.fn().mockResolvedValue(published);
584
+ // Typed directly at the boundary (no `as unknown as` cast) — every
585
+ // `MdxContentAdapter` member is stubbed, so the object literal satisfies
586
+ // the interface on its own.
587
+ const fakeAdapter: MdxContentAdapter = {
588
+ getPageBySlug,
589
+ getPageSource,
590
+ listPages: jest.fn(),
591
+ getDetailBySlug: jest.fn(),
592
+ listDetail: jest.fn(),
593
+ searchPages: jest.fn(),
594
+ extractStructuredData: jest.fn(),
595
+ createPage: jest.fn(),
596
+ updatePage: jest.fn(),
597
+ deletePage: jest.fn(),
598
+ getPageVersion: jest.fn(),
599
+ };
600
+ jest
601
+ .spyOn(contentAdapterModule, "getContentAdapter")
602
+ .mockReturnValue(fakeAdapter);
603
+
604
+ const res = await app.inject({
605
+ method: "GET",
606
+ url: "/v1/content/HomePage/probe?include=body",
607
+ });
608
+
609
+ expect(res.statusCode).toBe(200);
610
+ const body = JSON.parse(res.body);
611
+ // getPageBySlug is never called on this path — getPageSource alone
612
+ // supplies both halves, from the SAME read.
613
+ expect(getPageBySlug).not.toHaveBeenCalled();
614
+ expect(getPageSource).toHaveBeenCalledTimes(1);
615
+ expect(body.page.title).toBe(
616
+ "Published — the revision the single read actually returns",
617
+ );
618
+ expect(body.body).toBe("Published body.");
619
+ });
620
+ });
621
+
622
+ describe("GET /v1/content/:type/:slug?include=body under CONTENT_ADAPTER=database (#5890)", () => {
623
+ let app: FastifyInstance;
624
+
625
+ beforeEach(async () => {
626
+ jest.clearAllMocks();
627
+ process.env.CONTENT_ADAPTER = "database";
628
+ app = require("fastify")();
629
+ await app.register(contentRoutes);
630
+ });
631
+
632
+ afterEach(async () => {
633
+ delete process.env.CONTENT_ADAPTER;
634
+ await app.close();
635
+ });
636
+
637
+ // AC-2 — #5874's `getPageSource` on the database adapter, reached over HTTP.
638
+ it("serves the published version's body", async () => {
639
+ seedOneWorkspaceDeployment();
640
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue(
641
+ PUBLISHED_ARTIFACT,
642
+ );
643
+ (prisma.artifactVersion.findFirst as jest.Mock).mockResolvedValue(
644
+ PUBLISHED_VERSION,
645
+ );
646
+
647
+ const res = await app.inject({
648
+ method: "GET",
649
+ url: "/v1/content/HomePage/probe?include=body",
650
+ });
651
+
652
+ expect(res.statusCode).toBe(200);
653
+ const body = JSON.parse(res.body);
654
+ expect(body.body).toBe("Published body.");
655
+ expect(body.page.slug).toBe("probe");
656
+ // The body comes from the PUBLISHED pointer, not the draft tip.
657
+ expect(prisma.artifactVersion.findFirst).toHaveBeenCalledWith(
658
+ expect.objectContaining({
659
+ where: expect.objectContaining({ id: PUBLISHED_VERSION_ID }),
660
+ }),
661
+ );
662
+ // #5927 — ONE read of each table, not two: `page` and `body` come from
663
+ // the SAME `getPageSource` call, never a `getPageBySlug` call plus a
664
+ // second, independent `getPageSource` call.
665
+ expect(prisma.artifact.findFirst).toHaveBeenCalledTimes(1);
666
+ expect(prisma.artifactVersion.findFirst).toHaveBeenCalledTimes(1);
667
+ });
668
+
669
+ // AC-2 — the parameter buys no access a plain read does not have.
670
+ it("404s a draft-only page with the parameter, exactly as without it", async () => {
671
+ seedOneWorkspaceDeployment();
672
+ (prisma.artifact.findFirst as jest.Mock).mockResolvedValue({
673
+ ...PUBLISHED_ARTIFACT,
674
+ publishedVersionId: null,
675
+ });
676
+ (prisma.artifactVersion.findFirst as jest.Mock).mockResolvedValue({
677
+ ...PUBLISHED_VERSION,
678
+ id: "ver_draft",
679
+ isCurrent: true,
680
+ });
681
+
682
+ const withParam = await app.inject({
683
+ method: "GET",
684
+ url: "/v1/content/HomePage/probe?include=body",
685
+ });
686
+ const without = await app.inject({
687
+ method: "GET",
688
+ url: "/v1/content/HomePage/probe",
689
+ });
690
+
691
+ expect(withParam.statusCode).toBe(404);
692
+ expect(withParam.body).toBe(without.body);
693
+ expect(prisma.artifactVersion.findFirst).not.toHaveBeenCalled();
694
+ });
141
695
  });
@@ -120,3 +120,111 @@ describe("GET /api/content/:type", () => {
120
120
  expect(res.statusCode).toBe(400);
121
121
  });
122
122
  });
123
+
124
+ // ─── #5852 — the database path: which workspace a public list serves ────────
125
+ //
126
+ // Appended, so every MDX case above is untouched (AC-4). Same database-free
127
+ // convention as the sibling `get-page.test.ts` block.
128
+
129
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
130
+ jest.mock("../../../lib/prisma.js");
131
+
132
+ import {
133
+ DEFAULT_ORG_ID,
134
+ DEFAULT_WORKSPACE_ID,
135
+ } from "@working-theory/database/tenancy";
136
+
137
+ import { prisma } from "../../../lib/prisma.js";
138
+
139
+ const LISTED_VERSION_ID = "ver_listed";
140
+
141
+ /** A one-workspace deployment seeded with the well-known default. */
142
+ function seedOneWorkspaceDeployment(): void {
143
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(1);
144
+ (prisma.workspace.findUnique as jest.Mock).mockResolvedValue({
145
+ id: DEFAULT_WORKSPACE_ID,
146
+ organizationId: DEFAULT_ORG_ID,
147
+ });
148
+ }
149
+
150
+ describe("GET /v1/content/:type under CONTENT_ADAPTER=database (#5852)", () => {
151
+ let app: FastifyInstance;
152
+
153
+ beforeEach(async () => {
154
+ jest.clearAllMocks();
155
+ process.env.CONTENT_ADAPTER = "database";
156
+ app = require("fastify")();
157
+ await app.register(contentRoutes);
158
+ });
159
+
160
+ afterEach(async () => {
161
+ delete process.env.CONTENT_ADAPTER;
162
+ await app.close();
163
+ });
164
+
165
+ // The resolved arm — the seeded workspace's PUBLISHED pages, and nothing else.
166
+ it("lists the default workspace's published pages", async () => {
167
+ seedOneWorkspaceDeployment();
168
+ (prisma.artifact.findMany as jest.Mock).mockResolvedValue([
169
+ {
170
+ id: "art_listed",
171
+ workspaceId: DEFAULT_WORKSPACE_ID,
172
+ contentType: "text/page",
173
+ locale: "en",
174
+ slug: "listed",
175
+ title: "Listed",
176
+ publishedVersionId: LISTED_VERSION_ID,
177
+ },
178
+ // A draft-only artifact: it has no published pointer, so it never reaches
179
+ // the version fetch and never reaches the list.
180
+ {
181
+ id: "art_draft_only",
182
+ workspaceId: DEFAULT_WORKSPACE_ID,
183
+ contentType: "text/page",
184
+ locale: "en",
185
+ slug: "draft-only",
186
+ title: "Draft only",
187
+ publishedVersionId: null,
188
+ },
189
+ ]);
190
+ (prisma.artifactVersion.findMany as jest.Mock).mockResolvedValue([
191
+ {
192
+ id: LISTED_VERSION_ID,
193
+ artifactId: "art_listed",
194
+ workspaceId: DEFAULT_WORKSPACE_ID,
195
+ isCurrent: false,
196
+ content: {
197
+ type: "HomePage",
198
+ slug: "listed",
199
+ title: "Listed",
200
+ description: "The published revision of the listed page.",
201
+ body: "Published body.",
202
+ },
203
+ },
204
+ ]);
205
+
206
+ const res = await app.inject({ method: "GET", url: "/v1/content/HomePage" });
207
+
208
+ expect(res.statusCode).toBe(200);
209
+ const body = JSON.parse(res.body);
210
+ expect(body.items.map((item: { slug: string }) => item.slug)).toEqual([
211
+ "listed",
212
+ ]);
213
+ expect(prisma.artifact.findMany).toHaveBeenCalledWith(
214
+ expect.objectContaining({
215
+ where: expect.objectContaining({ workspaceId: DEFAULT_WORKSPACE_ID }),
216
+ }),
217
+ );
218
+ });
219
+
220
+ // The unresolved arm — 404 before any content query, never an empty 200
221
+ // `ItemList`, which would assert a site that did not resolve.
222
+ it("404s an ambiguous deployment before any content query", async () => {
223
+ (prisma.workspace.count as jest.Mock).mockResolvedValue(2);
224
+
225
+ const res = await app.inject({ method: "GET", url: "/v1/content/HomePage" });
226
+
227
+ expect(res.statusCode).toBe(404);
228
+ expect(prisma.artifact.findMany).not.toHaveBeenCalled();
229
+ });
230
+ });