create-ailk 0.5.0 → 0.6.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 (299) 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/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -109,8 +109,12 @@ export type ListLeadsResult =
109
109
  * `WAITLIST_DASHBOARD_ROLES` (`waitlist-dashboard.ts`); declared here rather
110
110
  * than imported because that file is a `signal-collectors` slice the strip
111
111
  * removes and this one ships at every level.
112
+ *
113
+ * Exported so `inbox-messages.ts` (#6204's tenant-scoped read/write, moved
114
+ * off this base file's own dependency edge — see that file's header) enforces
115
+ * the SAME floor rather than a copy.
112
116
  */
113
- const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
117
+ export const LEAD_INBOX_ROLES: ReadonlySet<string> = new Set(["owner", "admin"]);
114
118
 
115
119
  /**
116
120
  * The ONLY columns read out of the Lead table on this path.
@@ -162,8 +166,11 @@ const MAX_NAME_LENGTH = 200;
162
166
  * `payload` is JSON of a shape the site's own form decides, so reading one
163
167
  * key out of it is a boundary read: anything that is not a string is `null`,
164
168
  * and a string is truncated (see `MAX_NAME_LENGTH`).
169
+ *
170
+ * Exported so `inbox-messages.ts` reuses this exact narrowing — never a copy
171
+ * (see that file's header for why the shaping stays here).
165
172
  */
166
- function readName(payload: Prisma.JsonValue): string | null {
173
+ export function readName(payload: Prisma.JsonValue): string | null {
167
174
  if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
168
175
  return null;
169
176
  }
@@ -261,3 +268,17 @@ export async function listOwnedLeads(params: {
261
268
  },
262
269
  };
263
270
  }
271
+
272
+ // #6204's `listUnhandledMessages` / `markMessageHandled` — the tenant-scoped
273
+ // "Needs-you" read/write pair over `tenantDb(tenant).leads` — moved OFF this
274
+ // file to `./inbox-messages.js`. This file is a BASE-tier file every scaffold
275
+ // ships (this header's own claim); `tenantDb` (`../lib/tenant-db.js`) is a
276
+ // `multi-tenant`-slice file, and the paid-module exclusion closure
277
+ // (`packages/create-ailk/src/lib/import-graph.ts`) is backward — a base file
278
+ // that imports a paid seed is ITSELF added to the exclusion set, and so is
279
+ // everything that imports IT. Importing `tenantDb` from here once already
280
+ // pulled `server.ts`/`cli.ts`/`index.ts`/`vercel-handler.ts` out of a keyless
281
+ // scaffold (caught by `__tests__/paid-module-exclusions.test.ts`, #5678's own
282
+ // cascade recurring a third time). `readName()` and `LEAD_INBOX_ROLES` above
283
+ // stay here (both exported) and `inbox-messages.ts` imports them — the row
284
+ // shape and the role floor are the SAME constants, never a copy.
@@ -42,11 +42,18 @@ import {
42
42
  } from "@working-theory/validation";
43
43
 
44
44
  import { prisma } from "../lib/prisma.js";
45
- import { currentSiteId } from "../lib/site.js";
45
+ import { resolveSiteWorkspaceId } from "../lib/site.js";
46
46
 
47
47
  export async function listingConfigFor(siteId: string): Promise<SiteListingConfig> {
48
+ // #6257 — every caller hands this function an ALREADY-RESOLVED workspace id
49
+ // (a `ProjectListing` row's own `siteId`, an authenticated tenant's
50
+ // `workspaceId`, or `siteIdForPublicKey`'s return below), so `workspaceId`
51
+ // rides beside the unique `siteId` selector rather than being resolved a
52
+ // second time. `site_listing_configs_workspace_site_agree` makes the pair
53
+ // redundant by construction — which is the point: the tenant scope is now
54
+ // something the QUERY states, not something the caller is trusted to have.
48
55
  const row = await prisma.siteListingConfig.findUnique({
49
- where: { siteId },
56
+ where: { siteId, workspaceId: siteId },
50
57
  select: { data: true },
51
58
  });
52
59
  if (!row) return SITE_LISTING_CONFIG_DEFAULTS;
@@ -132,14 +139,18 @@ function warnOnDroppedField(
132
139
  * 1. A `SiteListingConfig` row whose `publicKey` matches. This is the
133
140
  * multi-tenant path and the only one that can name a site other than the
134
141
  * deployment's own.
135
- * 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to
136
- * `currentSiteId()`. This is the SINGLE-TENANT path: the default
137
- * `create-ailk` scaffold is one customer per deployment and has no
138
- * config row, so requiring one would mean a site could not accept an
139
- * application until an operator wrote a database row.
142
+ * 2. `AILK_LISTING_PUBLIC_KEY`, matched against the env, resolving to the
143
+ * RESOLVED workspace id for `currentSiteId()` (#6254 AC-6/AC-10 —
144
+ * `resolveSiteWorkspaceId()`, not the raw alias: `project_listings`
145
+ * now rejects a `site_id` that disagrees with `workspace_id`,
146
+ * database/migrations/20260922120000_tenancy_fence). This is the
147
+ * SINGLE-TENANT path: the default `create-ailk` scaffold is one
148
+ * customer per deployment and has no config row, so requiring one
149
+ * would mean a site could not accept an application until an operator
150
+ * wrote a database row.
140
151
  *
141
152
  * An unmatched key resolves to `null` and the route answers 400. It does NOT
142
- * fall through to `currentSiteId()`: a typo would otherwise silently file one
153
+ * fall through to the env path: a typo would otherwise silently file one
143
154
  * site's applications against another.
144
155
  *
145
156
  * The env comparison is a plain `===` on an opaque public identifier — there is
@@ -147,6 +158,13 @@ function warnOnDroppedField(
147
158
  * would imply a guarantee this value does not carry.
148
159
  */
149
160
  export async function siteIdForPublicKey(publicKey: string): Promise<string | null> {
161
+ // fence: global-key — `SiteListingConfig.publicKey`, a site's own public
162
+ // listing key, unique across every workspace. This is the query that
163
+ // RESOLVES the tenant: it is handed nothing but the key, and a workspace
164
+ // term here would have to come from the very answer it is computing. The
165
+ // key is not a credential (it ships in browser JavaScript by design, #5039)
166
+ // and holding it grants only the ability to open a draft; an unmatched key
167
+ // returns `null` and the route answers 400 rather than falling through.
150
168
  const row = await prisma.siteListingConfig.findUnique({
151
169
  where: { publicKey },
152
170
  select: { siteId: true },
@@ -154,7 +172,7 @@ export async function siteIdForPublicKey(publicKey: string): Promise<string | nu
154
172
  if (row) return row.siteId;
155
173
 
156
174
  const envKey = process.env["AILK_LISTING_PUBLIC_KEY"];
157
- if (envKey && envKey === publicKey) return currentSiteId();
175
+ if (envKey && envKey === publicKey) return resolveSiteWorkspaceId();
158
176
 
159
177
  return null;
160
178
  }
@@ -44,6 +44,10 @@
44
44
  import { createHash, randomBytes } from "node:crypto";
45
45
 
46
46
  import type { Prisma, Role } from "@working-theory/database";
47
+ import {
48
+ DEFAULT_WORKSPACE_SLUG,
49
+ resolveSentinelWorkspaceRow,
50
+ } from "@working-theory/database/tenancy";
47
51
  import { logger } from "@working-theory/observability";
48
52
  import {
49
53
  clampWindowDays,
@@ -217,6 +221,7 @@ const PROJECT_LISTING_SELECT = {
217
221
  name: true,
218
222
  oneLiner: true,
219
223
  problem: true,
224
+ solution: true,
220
225
  url: true,
221
226
  product: true,
222
227
  commitment: true,
@@ -362,6 +367,7 @@ function toResponseRow(
362
367
  headline: row.headline,
363
368
  oneLiner: row.oneLiner,
364
369
  problem: row.problem,
370
+ solution: row.solution,
365
371
  url: row.url,
366
372
  product: row.product as ProjectListing["product"],
367
373
  commitment: row.commitment as ProjectListing["commitment"],
@@ -428,8 +434,21 @@ async function attachWaitlistCounts(
428
434
  const counts = new Map<string, number>();
429
435
  await Promise.all(
430
436
  experimentKeys.map(async (experimentKey) => {
437
+ // fence: workspaceId === the scope id this query already filters siteId by
438
+ // (workspace_site_agree CHECK, 20260922120000_tenancy_fence). Filtering the
439
+ // CONSTRAINED column makes the scope correct by construction; the format
440
+ // CHECK makes a raw alias match nothing rather than by accident.
441
+ //
442
+ // What that id IS depends on the caller, and only two of the six are
443
+ // route-resolved: `listProjectListings` and `getProjectListing` receive
444
+ // `tenant.workspaceId`, so for them this is an isolation control.
445
+ // `listMyProjectListings` and the two `publicProjectListings` call sites
446
+ // pass a ProjectListing row's OWN `siteId`, so for them it is a
447
+ // CONSISTENCY control — the count agrees with the row it is reported
448
+ // against — exactly as `project-listing-decision.ts#waitlistCountFor`
449
+ // spells out (#6258 § 1.6 / § 5 D8, security review L-2).
431
450
  const count = await prisma.waitlistScore.count({
432
- where: { siteId, experimentKey },
451
+ where: { siteId, workspaceId: siteId, experimentKey },
433
452
  });
434
453
  counts.set(experimentKey, count);
435
454
  }),
@@ -602,7 +621,113 @@ export type StartProjectListingDraftResult =
602
621
  */
603
622
  verifyNonce: string;
604
623
  }
605
- | { ok: false; error: { kind: "invalid_input"; message: string } };
624
+ | { ok: false; error: { kind: "invalid_input"; message: string } }
625
+ // #6203 — the resolved `siteId` names no workspace this database holds.
626
+ // Distinct from `invalid_input` (a body problem): this is a server-side
627
+ // tenancy misconfiguration, and the route answers 503, never 400.
628
+ | { ok: false; error: { kind: "tenant_unresolved"; message: string } };
629
+
630
+ /**
631
+ * Resolves an ALREADY-RESOLVED `siteId` (never a caller-supplied string — see
632
+ * `startProjectListingDraft`'s own header) to its workspace id (#6203).
633
+ *
634
+ * Mirrors `apps/api/src/lib/site.ts`'s `resolveSiteWorkspaceId()` — the SAME
635
+ * two-branch alias mapping the #6203 migration's own backfill `CASE`
636
+ * encodes: the literal alias `"default"` resolves to the seeded sentinel
637
+ * workspace IFF that row exists and hangs off the sentinel Organization;
638
+ * any other alias resolves only by exact `workspaces.id` equality. Kept
639
+ * LOCAL and PARAMETERIZED rather than reusing `resolveSiteWorkspaceId()`
640
+ * directly: that function is hardwired to `currentSiteId()`
641
+ * (`AILK_SITE_ID`), while a `ProjectListing`'s `siteId` can legitimately
642
+ * name a DIFFERENT site under a multi-key deployment
643
+ * (`listing-config.ts#siteIdForPublicKey`'s `SiteListingConfig.publicKey`
644
+ * path) — the workspace stamped here must correspond to the SAME site the
645
+ * row's own `siteId` already names, not the deployment's default. NOT
646
+ * memoized (unlike `resolveSiteWorkspaceId()`): a different `siteId` per
647
+ * call means the sentinel-only cache that function keeps would be wrong
648
+ * here.
649
+ */
650
+ async function resolveWorkspaceIdForSite(siteId: string): Promise<string | null> {
651
+ if (siteId === DEFAULT_WORKSPACE_SLUG) {
652
+ const row = await resolveSentinelWorkspaceRow(prisma);
653
+ return row?.id ?? null;
654
+ }
655
+ const row = await prisma.workspace.findUnique({ where: { id: siteId } });
656
+ return row ? siteId : null;
657
+ }
658
+
659
+ /**
660
+ * #6314 — resolve the capturing listicle magnet, SERVER-SIDE, from the slug
661
+ * the body named.
662
+ *
663
+ * All four terms in ONE query, and only `slug` came from the caller:
664
+ * `siteId` and `workspaceId` are the value `startProjectListingDraft` has
665
+ * already resolved from the site's PUBLIC KEY (`siteIdForPublicKey`, then
666
+ * `resolveWorkspaceIdForSite`), never from the body. `@@unique([siteId,
667
+ * slug])` makes the match at most one row.
668
+ *
669
+ * `kind: "listicle"` is LOAD-BEARING, not cosmetic: without it a visitor
670
+ * could name a contact or newsletter magnet's slug — both are public path
671
+ * segments — and file an application against it.
672
+ *
673
+ * NO `status` term, deliberately. A magnet can be flipped `live -> draft`
674
+ * while a visitor is mid-application, and dropping attribution on that flip
675
+ * would silently punish the operator's own edit. The cost is bounded: a draft
676
+ * magnet's slug is the operator's own, the resulting row lands only in that
677
+ * operator's own workspace, and the Applications wire row carries `status`
678
+ * so a never-submitted row stays legible as one.
679
+ *
680
+ * Returns `unresolved` for a slug that names nothing, names another
681
+ * workspace's magnet, or names a magnet of the wrong kind — those three are
682
+ * deliberately indistinguishable, and all three write NULL.
683
+ *
684
+ * `failed` is a FOURTH outcome and is NOT one of them: the lookup itself threw.
685
+ * It also writes NULL, so the wire is unchanged, but the caller must not
686
+ * describe it as "no such magnet" — during a database incident that would send
687
+ * an operator hunting a magnet-configuration problem that does not exist. This
688
+ * function has already logged the accurate line by the time it returns `failed`.
689
+ */
690
+ type CapturingFlowResolution =
691
+ | { kind: "resolved"; id: string }
692
+ | { kind: "unresolved" }
693
+ | { kind: "failed" };
694
+
695
+ async function resolveCapturingFlowId(
696
+ workspaceId: string,
697
+ slug: string,
698
+ ): Promise<CapturingFlowResolution> {
699
+ try {
700
+ const row = await prisma.flowDefinition.findFirst({
701
+ where: {
702
+ slug,
703
+ // Both scope terms, both from the value resolved above. They are equal
704
+ // by the `flow_definitions_workspace_site_agree` CHECK the fence added;
705
+ // carrying both is what makes the scope explicit at the query rather
706
+ // than implied by a constraint a future migration could move.
707
+ siteId: workspaceId,
708
+ workspaceId,
709
+ kind: "listicle",
710
+ },
711
+ select: { id: true },
712
+ });
713
+ return row === null ? { kind: "unresolved" } : { kind: "resolved", id: row.id };
714
+ } catch (err) {
715
+ // The lookup is OPTIONAL attribution on a never-gate anonymous route: an
716
+ // unavailable read (statement timeout, connection blip) must degrade
717
+ // exactly like a slug that names nothing — NULL, and the application is
718
+ // kept — rather than turn into a 500 that loses a real founder's
719
+ // submission. Both outcomes are NULL on the row, so this line is the only
720
+ // thing that tells an operator "lookup failed" from "no such magnet".
721
+ // PII-SAFE, same idiom as the unresolved-slug line below: routing
722
+ // metadata plus the caught error; never the founder's identity, the
723
+ // resume token or the verify nonce (none is in scope here).
724
+ logger.warn(
725
+ { workspaceId, capturedByFlowSlug: slug, err },
726
+ "[project-listings] capturedByFlowSlug lookup failed; application stored unattributed",
727
+ );
728
+ return { kind: "failed" };
729
+ }
730
+ }
606
731
 
607
732
  /**
608
733
  * Create a `draft` from step one — the founder's name, email and consent.
@@ -621,11 +746,27 @@ export type StartProjectListingDraftResult =
621
746
  *
622
747
  * No slug yet: it is derived from `name` at SUBMIT (#5033 D2), which is the
623
748
  * first moment a name exists.
749
+ *
750
+ * WORKSPACE STAMP (#6203). The row's `workspaceId` is resolved from `siteId`
751
+ * itself (`resolveWorkspaceIdForSite` above) — never a client-supplied
752
+ * value, and never a fixed deployment default. An unresolved result rejects
753
+ * the write before any row is created (spec AC-4).
624
754
  */
625
755
  export async function startProjectListingDraft(
626
756
  siteId: string,
627
757
  input: ProjectListingStartInput,
628
758
  ): Promise<StartProjectListingDraftResult> {
759
+ const workspaceId = await resolveWorkspaceIdForSite(siteId);
760
+ if (workspaceId === null) {
761
+ return {
762
+ ok: false,
763
+ error: {
764
+ kind: "tenant_unresolved",
765
+ message: "This site is not configured to accept applications.",
766
+ },
767
+ };
768
+ }
769
+
629
770
  const { requestedWindowDays } = input;
630
771
  // #5407 — the NOT NULL display column. `composeFounderName` builds it from
631
772
  // the PARTS wherever both are written (even beside a `founderName` the body
@@ -684,10 +825,30 @@ export async function startProjectListingDraft(
684
825
  // wording, which nobody was shown.
685
826
  const consents = input.consents;
686
827
  const locale = input.locale ?? "en";
828
+ // #6314 — the capturing magnet, resolved SERVER-SIDE from the slug the body
829
+ // named. Only runs when the key is present, so an existing caller costs
830
+ // nothing; when it is, it is one probe of `@@unique([siteId, slug])`.
831
+ const capture =
832
+ input.capturedByFlowSlug === undefined
833
+ ? undefined
834
+ : await resolveCapturingFlowId(workspaceId, input.capturedByFlowSlug);
835
+ const capturedByFlowId = capture?.kind === "resolved" ? capture.id : null;
687
836
  const row = await prisma.$transaction(async (tx) => {
688
837
  const created = await tx.projectListing.create({
689
838
  data: {
690
- siteId,
839
+ // #6254 AC-3/AC-6/AC-10 — the RESOLVED workspace id, not the raw
840
+ // `siteId` parameter: `project_listings` now requires
841
+ // `workspace_id = site_id`
842
+ // (database/migrations/20260922120000_tenancy_fence), and this
843
+ // column is the isolation key every downstream reader (including
844
+ // `submitProjectListingDraft`'s `routeLead` call) reads back as
845
+ // `draft.siteId`. `siteId` (the public-facing param, possibly the
846
+ // raw `"default"` alias) is still used above to look up
847
+ // `listingConfigFor`/`consentConfigForSite` — those are independent,
848
+ // unfenced reads keyed on the site concept, not this row.
849
+ siteId: workspaceId,
850
+ // #6203 — the isolation key, resolved above from this SAME siteId.
851
+ workspaceId,
691
852
  founderName,
692
853
  // #5407 — the parts as sent, beside the composed line. Absent where a
693
854
  // caller sent only a display line, which is the shape every row
@@ -705,6 +866,11 @@ export async function startProjectListingDraft(
705
866
  resumeTokenHash: hashResumeToken(resumeToken),
706
867
  emailVerifyNonceHash: hashVerifyNonce(verifyNonce),
707
868
  windowDays,
869
+ // #6314 — WRITE-ONCE, and only here. `draftUpdateData` never learns
870
+ // this key, so no patch body and no submit can re-attribute a draft
871
+ // to a different magnet after the fact — not by its holder, and not
872
+ // by anyone who obtains its resume token.
873
+ ...(capturedByFlowId !== null && { capturedByFlowId }),
708
874
  },
709
875
  select: { id: true },
710
876
  });
@@ -717,6 +883,33 @@ export async function startProjectListingDraft(
717
883
  return created;
718
884
  });
719
885
 
886
+ // #6314 — an unresolved slug is the operator's ONLY signal, precisely
887
+ // because the response deliberately carries none (a distinguishable error
888
+ // would be a cross-workspace magnet-slug oracle on an anonymous route).
889
+ // Without this line, a mis-wired or stale consuming site yields a
890
+ // permanently empty Applications list an operator cannot tell from "no
891
+ // applications yet".
892
+ //
893
+ // PII-SAFE, in this route family's existing idiom: routing metadata only.
894
+ // Never `founderEmail`, `founderName`, the resume token or the verify
895
+ // nonce — the last two never leave this function except inside the email.
896
+ // ONLY the `unresolved` outcome, never `failed`. Both store NULL and both
897
+ // return the same 201, but only one of them establishes "no listicle magnet
898
+ // of that slug in this site". Asserting it on the throw path would put a
899
+ // false magnet-configuration signal into the log during a database incident,
900
+ // which is exactly when an operator is grepping for a cause. `failed` has its
901
+ // own, accurate line, emitted inside `resolveCapturingFlowId`.
902
+ if (capture?.kind === "unresolved") {
903
+ logger.warn(
904
+ {
905
+ siteId,
906
+ listingId: row.id,
907
+ capturedByFlowSlug: input.capturedByFlowSlug,
908
+ },
909
+ "[project-listings] capturedByFlowSlug resolved to no listicle magnet in this site; application stored unattributed",
910
+ );
911
+ }
912
+
720
913
  return { ok: true, id: row.id, resumeToken, verifyNonce };
721
914
  }
722
915
 
@@ -891,6 +1084,7 @@ function draftUpdateData(
891
1084
  ...(patch.headline !== undefined && { headline: patch.headline }),
892
1085
  ...(patch.oneLiner !== undefined && { oneLiner: patch.oneLiner }),
893
1086
  ...(patch.problem !== undefined && { problem: patch.problem }),
1087
+ ...(patch.solution !== undefined && { solution: patch.solution }),
894
1088
  ...(patch.url !== undefined && { url: patch.url }),
895
1089
  ...(patch.product !== undefined && { product: patch.product }),
896
1090
  ...(patch.commitment !== undefined && { commitment: patch.commitment }),
@@ -1067,15 +1261,16 @@ function applicationFromRow(
1067
1261
  address: row.address,
1068
1262
  ...(row.founderCompany !== null && { founderCompany: row.founderCompany }),
1069
1263
  questions: row.questions,
1070
- // #5285 — the three fields that became writable. Each is optional in the
1071
- // application schema, so a row that never carried one simply omits it and
1072
- // `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
1264
+ // #5285/#5096 — the four fields that became writable. Each is optional in
1265
+ // the application schema, so a row that never carried one simply omits it
1266
+ // and `submit` is unaffected. `requestedWindowDays` reads back the CLAMPED
1073
1267
  // `windowDays` the row actually holds, not what was asked for: the
1074
1268
  // request is not stored anywhere, and re-asserting it would be asserting
1075
1269
  // a number nothing ever agreed to.
1076
1270
  ...(row.logoMarkUrl !== null && { logoMarkUrl: row.logoMarkUrl }),
1077
1271
  ...(row.logoWordmarkUrl !== null && { logoWordmarkUrl: row.logoWordmarkUrl }),
1078
1272
  ...(row.windowDays !== null && { requestedWindowDays: row.windowDays }),
1273
+ ...(row.solution !== null && { solution: row.solution }),
1079
1274
  };
1080
1275
  return Object.fromEntries(
1081
1276
  Object.entries(all).filter(([key]) => collected.has(key)),
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Public-read site resolution — which workspace an UNAUTHENTICATED content
3
+ * read serves (#5852, roadmap D4 read half).
4
+ *
5
+ * THE GUARD MODEL IS FAIL-CLOSED. The only branch that yields a workspace is an
6
+ * unambiguously one-workspace deployment, answered by the same predicate the
7
+ * authenticated path uses (`services/default-tenant.ts#resolveDefaultWorkspaceId`)
8
+ * — one predicate, reached two ways, never two predicates. Zero workspaces, two
9
+ * or more, or a sole workspace that is not the seeded default all resolve to
10
+ * `unresolved`, and the caller answers 404 before any content query runs.
11
+ *
12
+ * IT READS NO HOST. Every deployment this resolver serves today is
13
+ * one-workspace, so a host lookup would be dead code — and there is nothing to
14
+ * look up: `WorkspaceSiteIdentity` carries no host column (the value lives
15
+ * inside the unindexed `data.url`), and `request.hostname` resolves from
16
+ * `X-Forwarded-Host` under this server's `trustProxy: true` (`server.ts`),
17
+ * which is caller-suppliable and therefore not a tenancy input. The
18
+ * multi-tenant host branch is #3635's, and must resolve against a server-side
19
+ * binding rather than a forwarded header.
20
+ *
21
+ * THE CLIENT NEVER SUPPLIES A WORKSPACE. No path parameter, query string,
22
+ * header or body field is read here, on any read. That is roadmap D4's own
23
+ * STOP boundary, not a default.
24
+ *
25
+ * IT IS A NO-OP OFF THE DATABASE ADAPTER. Unless `CONTENT_ADAPTER=database`,
26
+ * the function returns `not-required` having issued no query and loaded no
27
+ * database module — which is what keeps the MDX default path byte-identical
28
+ * and keeps the no-`DATABASE_URL` scaffold starting.
29
+ */
30
+ import {
31
+ resolvedAdapterKind,
32
+ type ArtifactPrismaClient,
33
+ type ContentAdapter,
34
+ } from "@working-theory/content-adapters";
35
+ import type { FastifyRequest } from "fastify";
36
+
37
+ import { getContentAdapter } from "../lib/content-adapter.js";
38
+
39
+ /**
40
+ * The three answers a public read can get. `reason` exists for `request.log`
41
+ * only: the route decides its response from `kind` alone, so no resolution
42
+ * detail reaches an unauthenticated caller (#5852 D7, D8).
43
+ */
44
+ export type PublicSiteResolution =
45
+ | { kind: "not-required" }
46
+ | { kind: "resolved"; workspaceId: string }
47
+ | { kind: "unresolved"; reason: "ambiguous" | "host-unmatched" };
48
+
49
+ /**
50
+ * Resolves the workspace a public content read serves, or refuses to.
51
+ *
52
+ * The `request` argument is the site identifier's eventual source (#3635) and
53
+ * is deliberately unused today — see the no-host note in this file's header.
54
+ */
55
+ export async function resolvePublicWorkspace(
56
+ _request: FastifyRequest,
57
+ ): Promise<PublicSiteResolution> {
58
+ if (resolvedAdapterKind() !== "database") {
59
+ return { kind: "not-required" };
60
+ }
61
+
62
+ // Deferred — see the module-load note in this file's header (#5852 D6).
63
+ const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
64
+ const workspaceId = await resolveDefaultWorkspaceId();
65
+ if (workspaceId === null) {
66
+ return { kind: "unresolved", reason: "ambiguous" };
67
+ }
68
+ return { kind: "resolved", workspaceId };
69
+ }
70
+
71
+ /**
72
+ * The adapter a public content read should use, or `null` when it must refuse.
73
+ *
74
+ * This is the ONE place the three-armed resolution collapses into a decision,
75
+ * so a second public read route cannot grow a second copy of it: `resolved`
76
+ * and `not-required` both yield an adapter, `unresolved` yields `null`. The
77
+ * caller answers `null` with its OWN existing not-found body — which is what
78
+ * keeps a refusal byte-identical to a missing page on `get-page`, and keeps
79
+ * each route's 404 shape its own (#5852 D8). The reason is logged here and
80
+ * never leaves the server.
81
+ */
82
+ export async function resolvePublicContentAdapter(
83
+ request: FastifyRequest,
84
+ ): Promise<ContentAdapter | null> {
85
+ const site = await resolvePublicWorkspace(request);
86
+ if (site.kind === "unresolved") {
87
+ request.log.warn(
88
+ { reason: site.reason },
89
+ "[Content] no site resolved for public read",
90
+ );
91
+ return null;
92
+ }
93
+ if (site.kind === "not-required") {
94
+ return getContentAdapter();
95
+ }
96
+
97
+ // The database path, and the only caller that needs apps/api's client. The
98
+ // import is deferred for the reason the header gives (#5852 D6), and the
99
+ // generated client is structurally wider than the narrow read surface the
100
+ // adapter declares — the cast names that, and nothing else.
101
+ const { prisma } = await import("../lib/prisma.js");
102
+ return getContentAdapter({
103
+ workspaceId: site.workspaceId,
104
+ prisma: prisma as unknown as ArtifactPrismaClient,
105
+ });
106
+ }