create-ailk 0.4.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 (396) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +12248 -0
  3. package/component-catalog.md +193 -68
  4. package/dist/cli.js +29 -1
  5. package/dist/derive-architecture.js +5 -2
  6. package/dist/lib/api-boot-assertion.d.ts +46 -0
  7. package/dist/lib/api-boot-assertion.js +70 -0
  8. package/dist/lib/extract-module-bundle.d.ts +50 -2
  9. package/dist/lib/extract-module-bundle.js +223 -15
  10. package/dist/lib/fetch-guard.d.ts +28 -0
  11. package/dist/lib/fetch-guard.js +101 -0
  12. package/dist/lib/fetch-module.js +4 -0
  13. package/dist/lib/fetch-template.js +4 -0
  14. package/dist/lib/import-graph.d.ts +29 -0
  15. package/dist/lib/import-graph.js +140 -0
  16. package/dist/module-architecture.d.ts +60 -8
  17. package/dist/module-architecture.js +587 -242
  18. package/dist/parse-args.js +6 -2
  19. package/dist/programmatic.d.ts +10 -0
  20. package/dist/programmatic.js +134 -19
  21. package/dist/site-chrome.d.ts +80 -0
  22. package/dist/site-chrome.js +135 -0
  23. package/dist/surfaces.d.ts +21 -6
  24. package/dist/surfaces.js +209 -36
  25. package/dist/template-manifest.d.ts +88 -3
  26. package/dist/template-manifest.js +281 -29
  27. package/dist/template-names.d.ts +1 -1
  28. package/dist/template-names.js +2 -0
  29. package/package.json +10 -2
  30. package/site-templates/_base/ailk-template.json +6 -0
  31. package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
  34. package/site-templates/marketing-site/ailk-template.json +11 -0
  35. package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
  36. package/site-templates/marketing-site/provenance.json +4 -0
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
  38. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
  39. package/site-templates/marketing-site-free/ailk-template.json +2 -1
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
  41. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
  42. package/site-templates/minimalism-site/ailk-template.json +22 -0
  43. package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
  44. package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
  45. package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
  46. package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
  47. package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
  48. package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
  49. package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
  50. package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
  51. package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
  52. package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
  53. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  54. package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
  55. package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
  56. package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
  57. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
  58. package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
  59. package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
  60. package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
  61. package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
  62. package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
  63. package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
  64. package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
  65. package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
  66. package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
  67. package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
  68. package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
  69. package/site-templates/minimalism-site/provenance.json +4 -0
  70. package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
  71. package/site-templates/saas-product/ailk-template.json +16 -0
  72. package/site-templates/saas-product/provenance.json +10 -0
  73. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  74. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  75. package/site-templates/simple-service/ailk-template.json +10 -0
  76. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  77. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  78. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  79. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  80. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  81. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  82. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  83. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  84. package/site-templates/simple-service/provenance.json +4 -0
  85. package/templates/.claude/skills/README.md +16 -25
  86. package/templates/.env.example +102 -12
  87. package/templates/.github/workflows/ci.yml +48 -0
  88. package/templates/README.md +3 -1
  89. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  90. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  91. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  92. package/templates/apps/api/.env.example +13 -0
  93. package/templates/apps/api/CLAUDE.md +29 -2
  94. package/templates/apps/api/eslint.config.mjs +69 -0
  95. package/templates/apps/api/jest.config.cjs +3 -0
  96. package/templates/apps/api/package.json +7 -2
  97. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  98. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
  99. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
  100. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  101. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  102. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
  103. package/templates/apps/api/src/config/index.ts +62 -0
  104. package/templates/apps/api/src/config/modules.ts +7 -3
  105. package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
  106. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  107. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  108. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  109. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  110. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  111. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  112. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  113. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  114. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  115. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  116. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  117. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  118. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  119. package/templates/apps/api/src/lib/site.ts +208 -9
  120. package/templates/apps/api/src/lib/stripe.ts +66 -0
  121. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
  122. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
  123. package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
  124. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  125. package/templates/apps/api/src/openapi/spec.ts +543 -2
  126. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  127. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  128. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  129. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  130. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  131. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  132. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  133. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  134. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  135. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  136. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  137. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  138. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  139. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  140. package/templates/apps/api/src/routes/content/index.ts +13 -73
  141. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  142. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  143. package/templates/apps/api/src/routes/content/search.ts +129 -0
  144. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  145. package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
  146. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  147. package/templates/apps/api/src/routes/leads/list.ts +104 -0
  148. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  149. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  150. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  151. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  152. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  153. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  154. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  155. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  156. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  157. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  158. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  159. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  160. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  161. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  162. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  163. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  164. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  165. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  166. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  167. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  168. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  169. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  170. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  171. package/templates/apps/api/src/server.ts +521 -55
  172. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  173. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  174. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  175. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  176. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  177. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  178. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  179. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
  180. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  181. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
  182. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  183. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  184. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  185. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  186. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  187. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  188. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  189. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  190. package/templates/apps/api/src/services/audit-log.ts +668 -0
  191. package/templates/apps/api/src/services/consent.ts +18 -0
  192. package/templates/apps/api/src/services/default-tenant.ts +300 -0
  193. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  194. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  195. package/templates/apps/api/src/services/entitlements.ts +80 -0
  196. package/templates/apps/api/src/services/lead-promotion.ts +160 -0
  197. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  198. package/templates/apps/api/src/services/leads-inbox.ts +284 -0
  199. package/templates/apps/api/src/services/listing-config.ts +27 -9
  200. package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
  201. package/templates/apps/api/src/services/project-listings.ts +201 -6
  202. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  203. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  204. package/templates/apps/api/src/services/revalidation.ts +173 -0
  205. package/templates/apps/api/src/services/verified-email.ts +71 -0
  206. package/templates/apps/api/src/vercel-handler.ts +75 -35
  207. package/templates/apps/mcp/.env.example +17 -0
  208. package/templates/apps/mcp/CLAUDE.md +58 -25
  209. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  210. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  211. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  212. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  213. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  214. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  215. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  216. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  217. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  218. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  219. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  220. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  221. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  222. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  223. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  224. package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
  225. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  226. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  227. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  228. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  229. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  230. package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
  231. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  232. package/templates/apps/mcp/eslint.config.mjs +69 -0
  233. package/templates/apps/mcp/jest.config.cjs +13 -0
  234. package/templates/apps/mcp/package.json +8 -2
  235. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  236. package/templates/apps/mcp/src/config/modules.ts +10 -5
  237. package/templates/apps/mcp/src/http-transport.ts +102 -29
  238. package/templates/apps/mcp/src/index.ts +73 -11
  239. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  240. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  241. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  242. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  243. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  244. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  245. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  246. package/templates/apps/mcp/src/server.ts +61 -37
  247. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  248. package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
  249. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  250. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  251. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  252. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  253. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  254. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  255. package/templates/apps/mcp/src/tools/index.ts +9 -7
  256. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  257. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  258. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  259. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  260. package/templates/apps/web/.env.example +74 -8
  261. package/templates/apps/web/CLAUDE.md +46 -0
  262. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  263. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  264. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  265. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  266. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  267. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  268. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  269. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  270. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  271. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  272. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  273. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  274. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  275. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  276. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  277. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  278. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  279. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  280. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  281. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
  282. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
  283. package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
  284. package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
  285. package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
  286. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
  287. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
  288. package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
  289. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  290. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
  291. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
  292. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
  293. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
  294. package/templates/apps/web/app/[locale]/layout.tsx +308 -65
  295. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  296. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  297. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  298. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  299. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  300. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  301. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  302. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  303. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  304. package/templates/apps/web/auth.ts +407 -8
  305. package/templates/apps/web/components/google-analytics.tsx +18 -1
  306. package/templates/apps/web/eslint.config.mjs +69 -0
  307. package/templates/apps/web/jest.config.cjs +15 -19
  308. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  309. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  310. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  311. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
  312. package/templates/apps/web/lib/auth-email.ts +318 -0
  313. package/templates/apps/web/lib/auth-env.ts +85 -0
  314. package/templates/apps/web/lib/auth-routes.ts +55 -0
  315. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  316. package/templates/apps/web/lib/nav-config.ts +27 -7
  317. package/templates/apps/web/lib/server-api.ts +1 -1
  318. package/templates/apps/web/lib/site-chrome.ts +29 -0
  319. package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
  320. package/templates/apps/web/messages/en.json +216 -2
  321. package/templates/apps/web/next.config.mjs +20 -0
  322. package/templates/apps/web/package.json +8 -3
  323. package/templates/apps/web/sentry.client.config.ts +131 -0
  324. package/templates/apps/web/sentry.edge.config.ts +72 -0
  325. package/templates/apps/web/sentry.server.config.ts +72 -0
  326. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  327. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  328. package/templates/apps/web/src/lib/routes.ts +13 -18
  329. package/templates/brand_assets/README.md +24 -0
  330. package/templates/content/en/AboutPage/about.mdx +160 -15
  331. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  332. package/templates/content/en/ContactPage/contact.mdx +90 -13
  333. package/templates/content/en/FAQPage/faq.mdx +62 -22
  334. package/templates/content/en/HomePage/home.mdx +244 -20
  335. package/templates/content/en/LandingPage/blog.mdx +83 -0
  336. package/templates/content/en/LandingPage/features.mdx +103 -0
  337. package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
  338. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  339. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  340. package/templates/database/CHANGELOG.md +149 -0
  341. package/templates/database/README.md +10 -3
  342. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  343. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  344. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  345. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  346. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  347. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  348. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  349. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  350. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  351. package/templates/database/__tests__/seed.test.ts +1 -1
  352. package/templates/database/__tests__/tenancy.test.ts +141 -0
  353. package/templates/database/config/schema.prisma +79 -2
  354. package/templates/database/content/schema.prisma +87 -11
  355. package/templates/database/eslint.config.mjs +69 -0
  356. package/templates/database/inbox/schema.prisma +452 -9
  357. package/templates/database/jest.config.cjs +5 -3
  358. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  359. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  360. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  361. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  362. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  363. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  364. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  365. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  366. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  367. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  368. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  369. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  370. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  371. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  372. package/templates/database/package.json +7 -3
  373. package/templates/database/seed.ts +1 -1
  374. package/templates/database/tenancy.ts +86 -1
  375. package/templates/database/tsconfig.json +1 -0
  376. package/templates/package.json +1 -14
  377. package/versions.json +52 -0
  378. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
  379. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  380. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  381. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  382. package/templates/apps/api/src/routes/content/create.ts +0 -115
  383. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  384. package/templates/apps/api/src/routes/content/update.ts +0 -112
  385. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  386. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  387. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  388. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  389. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  390. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  391. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  392. /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
  393. /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
  394. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  395. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
  396. /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @file owner-workspaces.ts
3
+ * @description The ONE site→workspace resolution in `apps/api`.
4
+ *
5
+ * A `Lead` is keyed on `siteId` and a `Workspace` on `id`, but the two are the
6
+ * SAME value space named per namespace — `database/config/schema.prisma` says
7
+ * as much of `ProjectListing`, "the one keyed on `siteId` rather than
8
+ * `workspaceId`". So ownership is the direct membership test
9
+ * `siteId ∈ workspaceIds`, never a join through a mapping table, and every
10
+ * caller that needs it resolves it here rather than writing a second path: a
11
+ * divergent isolation path across two PII surfaces is exactly the thing that
12
+ * turns into rework when the multi-tenant product arrives.
13
+ *
14
+ * Callers: `lead-promotion.ts` (lead → user promotion, #3586) and
15
+ * `leads-inbox.ts` (the owner's leads read, #5678).
16
+ *
17
+ * ## Why this is its own file rather than a `lead-promotion.ts` export
18
+ *
19
+ * `paidModuleTemplateExclusions()` (`packages/create-ailk/src/module-architecture.ts`)
20
+ * walks the import graph from each paid module's `ownedFiles` to a fixed point
21
+ * and drops every file that reaches one, so that a keyless scaffold carries no
22
+ * paid source. `lead-promotion.ts` is inside that closure and always has been,
23
+ * which means anything importing it is dropped too. Putting this function
24
+ * there and importing it from the leads read would have carried the closure
25
+ * through `leads-inbox.ts` and `routes/leads/list.ts` into `server.ts`, and a
26
+ * scaffold with no server entry point does not build — the same base-breaking
27
+ * cascade `module-architecture.ts` records twice in its own comments, measured
28
+ * here before this file existed. Keeping the resolution in a module that
29
+ * reaches nothing excluded is what lets both callers share it.
30
+ *
31
+ * Nothing in this file may name `tenant-context.js` in an import form,
32
+ * including inside a comment: the closure's scanner is a regular expression
33
+ * over the file's text and does not know which lines are code.
34
+ */
35
+
36
+ import type { Id } from "@working-theory/ids";
37
+
38
+ import { isModuleEnabled } from "../config/modules.js";
39
+ import { loadSlice } from "../lib/slice-load.js";
40
+
41
+ /**
42
+ * The tenant-context module is a multi-tenant SLICE the OSS strip removes
43
+ * (#5182), and `listTenancy` is a VALUE — a runtime call, not an erasable type
44
+ * — so a static import, or a specifier written literally at the call site,
45
+ * would be TS2307 on the public tree: `tsc` resolves a string-literal
46
+ * specifier whether or not the branch containing it can ever run.
47
+ *
48
+ * The remedy is the one `server.ts` and `lead-promotion.ts` already use: gate
49
+ * the call on `isModuleEnabled('multi-tenant')` and read the specifier from
50
+ * this constant, so it is not a literal at the call site. What defeats
51
+ * resolution is the INDIRECTION, not the type of the value. The constant form
52
+ * matters too — `@vercel/nft` constant-propagates a module-level binding into
53
+ * the dynamic call and emits the file, which a table lookup would hide from it
54
+ * (#5304 D3).
55
+ *
56
+ * `apps/api` is a `tsc`-only surface with no bundler, which is the
57
+ * precondition that makes this sound here.
58
+ */
59
+ const TENANT_CONTEXT_SPECIFIER = "./tenant-context.js";
60
+
61
+ /** The one export this file needs from the multi-tenant slice. */
62
+ interface TenantContextSlice {
63
+ listTenancy: (params: {
64
+ userId: Id<"user">;
65
+ sessionTenant: undefined;
66
+ }) => Promise<
67
+ | {
68
+ ok: true;
69
+ value: {
70
+ organizations: Array<{
71
+ role: string;
72
+ workspaces: Array<{ id: string }>;
73
+ }>;
74
+ };
75
+ }
76
+ | { ok: false; error: { message: string } }
77
+ >;
78
+ }
79
+
80
+ /**
81
+ * One workspace the caller belongs to, carrying the role they hold in its
82
+ * organization. `role` is the organization-level `Membership.role`
83
+ * (`owner | admin | member`) the tenancy assembler already returns — typed as a
84
+ * plain string here rather than `MembershipRole`, because that type lives in
85
+ * the stripped slice this file may not name.
86
+ */
87
+ export type OwnerWorkspace = { id: string; role: string };
88
+
89
+ export type ResolveOwnerWorkspacesResult =
90
+ | { ok: true; value: OwnerWorkspace[] }
91
+ | { ok: false; error: { message: string } };
92
+
93
+ /**
94
+ * Resolve the caller's OWN workspaces.
95
+ *
96
+ * Delegates to the sole cross-namespace tenancy assembler — this file never
97
+ * issues a raw membership or workspace model query of its own, which
98
+ * `architecture.yaml`'s tenant-scope forbidden pattern reserves to the
99
+ * tenant-context and tenant-db modules.
100
+ *
101
+ * Module-conditional, and #5677 changed what that MEANS rather than what it
102
+ * does. `services/default-tenant.ts` now resolves the seeded default
103
+ * Workspace on every build, so a module-less deployment does have a
104
+ * resolvable workspace — the empty result below is a deliberate scope limit,
105
+ * not a statement that there is nothing to resolve. Wiring this resolver to
106
+ * that default is a behavior change for BOTH callers at once (promotion would
107
+ * begin stamping `promotedUserId` on single-tenant leads, which
108
+ * `/v1/flow-checkouts/me` and `/v1/waitlist-signups/me` join through), and it
109
+ * needs the deployment to pin `AILK_SITE_ID` to `DEFAULT_WORKSPACE_ID` —
110
+ * `siteId` comes from `lib/site.ts` while `workspaceId` is the sentinel, and
111
+ * `siteId ∈ workspaceIds` only matches when those agree.
112
+ */
113
+ export async function resolveOwnerWorkspaces(
114
+ userId: Id<"user">,
115
+ ): Promise<ResolveOwnerWorkspacesResult> {
116
+ if (!isModuleEnabled("multi-tenant")) return { ok: true, value: [] };
117
+
118
+ const specifier: string = TENANT_CONTEXT_SPECIFIER;
119
+ const { listTenancy } = (await loadSlice(
120
+ specifier,
121
+ () => import(specifier),
122
+ )) as TenantContextSlice;
123
+ const tenancy = await listTenancy({ userId, sessionTenant: undefined });
124
+ if (!tenancy.ok) return { ok: false, error: tenancy.error };
125
+
126
+ return {
127
+ ok: true,
128
+ value: tenancy.value.organizations.flatMap((org) =>
129
+ org.workspaces.map((workspace) => ({ id: workspace.id, role: org.role })),
130
+ ),
131
+ };
132
+ }
@@ -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
+ }