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
@@ -56,6 +56,15 @@ export interface ConsentWriteClient {
56
56
 
57
57
  interface ConsentGrantRow {
58
58
  siteId: string;
59
+ /**
60
+ * #6374 — the tenancy fence's isolation key, equal to `siteId` by
61
+ * construction. Non-optional on purpose: `consent_grants.workspace_id` is
62
+ * NOT NULL as of
63
+ * database/migrations/20260922120000_tenancy_fence, so a row builder that
64
+ * forgot it would fail at runtime; declaring it required here makes the
65
+ * type checker say so instead.
66
+ */
67
+ workspaceId: string;
59
68
  subjectType: string;
60
69
  subjectId: string;
61
70
  purposeId: string;
@@ -169,6 +178,12 @@ export async function recordConsent(
169
178
  const { statement, locale } = resolveConsentStatement(purpose, input.locale);
170
179
  rows.push({
171
180
  siteId: input.siteId,
181
+ // #6374 — the isolation key, equal to `siteId` by construction.
182
+ // `input.siteId` is the caller's already-resolved site id; the
183
+ // `consent_grants_workspace_site_agree` CHECK makes a disagreement
184
+ // unstorable, and `consent_grants_workspace_id_format` rejects a raw
185
+ // alias loudly. Never read from a request body (AC-6).
186
+ workspaceId: input.siteId,
172
187
  subjectType: input.subjectType,
173
188
  subjectId: input.subjectId,
174
189
  purposeId,
@@ -222,6 +237,9 @@ export async function recordLegacyConsent(
222
237
  data: [
223
238
  {
224
239
  siteId: input.siteId,
240
+ // #6374 — the isolation key, equal to `siteId` by construction; the
241
+ // same stamp the D10 path's row builder above applies.
242
+ workspaceId: input.siteId,
225
243
  subjectType: input.subjectType,
226
244
  subjectId: input.subjectId,
227
245
  purposeId: purpose.id,
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Default-tenant resolution — the BASE-build half of the tenant guard (#5677).
3
+ *
4
+ * A single-tenant site is a ONE-WORKSPACE TENANT, not a special case. The
5
+ * default Organization / Workspace / operator Membership are already declared
6
+ * as fixed reserved-range sentinels in `database/tenancy.ts` and already
7
+ * seeded by `database/seed.ts`, so there is nothing to synthesize here — this
8
+ * file resolves the seeded default and nothing else.
9
+ *
10
+ * WHY IT LIVES IN THE BASE BUILD. `services/tenant-context.ts` and
11
+ * `middleware/tenant.ts` are multi-tenant SLICE files (`// oss: false`), so on
12
+ * a build without that module they are not present and the routes behind them
13
+ * were never REGISTERED — not denied (#5677). The default
14
+ * resolution and the `TenantContext` shape it yields are needed on every
15
+ * build, so they live here, in a file that always ships; the multi-tenant
16
+ * slice imports this one rather than carrying its own copy, which is what
17
+ * keeps the two paths from drifting.
18
+ *
19
+ * PRECEDENCE, NOT PARALLELISM. When the multi-tenant module is present,
20
+ * `resolveActiveTenant` (the slice) is the entry point and calls
21
+ * `resolveDefaultTenant` only on the branch it always called it on — the
22
+ * previously-unconditional-deny null-binding branch. Nothing about the
23
+ * multi-tenant path changes. Without the module, `middleware/default-tenant.ts`
24
+ * calls this directly. There is one resolution, reached two ways, never two
25
+ * resolutions.
26
+ *
27
+ * DEFAULT-DENY IS THE FLOOR. `resolveDefaultTenant` returns `null` — never a
28
+ * grant — the instant the deployment is not unambiguously single-tenant
29
+ * (a second Workspace exists, or the sole Workspace is not the well-known
30
+ * default), and `null` again for a caller holding no Membership on the default
31
+ * Organization. Both branches were written for the multi-tenant slice by the
32
+ * owner-pin fix (docs/specs/single-tenant-owner-pin-spec.md, #3631) and are
33
+ * moved here BYTE-FOR-BYTE, not rewritten.
34
+ *
35
+ * This file is a sanctioned exception to architecture.yaml's tenant-scope
36
+ * forbidden pattern, for the same reason `tenant-context.ts` is: it is a
37
+ * tenancy-model boundary, not a domain-data reader.
38
+ */
39
+ import {
40
+ DEFAULT_ORG_ID,
41
+ OPERATOR_MEMBERSHIP_ID,
42
+ resolveDefaultWorkspaceRow as resolveDefaultWorkspaceRowShared,
43
+ } from "@working-theory/database/tenancy";
44
+ import type { Id } from "@working-theory/ids";
45
+
46
+ import { prisma } from "../lib/prisma.js";
47
+
48
+ import { findVerifiedEmail } from "./verified-email.js";
49
+
50
+ export type MembershipRole = "owner" | "admin" | "member";
51
+
52
+ /**
53
+ * The tenant scope every domain query is bound to. Ids carry the ADR-0031
54
+ * brand; values originating from Prisma reads are brand-asserted at the read
55
+ * edge (the DB CHECK constraints are the runtime guarantee).
56
+ */
57
+ export interface TenantContext {
58
+ userId: Id<"user">;
59
+ organizationId: Id<"organization">;
60
+ workspaceId: Id<"workspace">;
61
+ role: MembershipRole;
62
+ }
63
+
64
+ export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
65
+
66
+ export type TenantResult<T> =
67
+ | { ok: true; value: T }
68
+ | { ok: false; error: { kind: TenantErrorKind; message: string } };
69
+
70
+ export function isUniqueViolation(error: unknown): boolean {
71
+ return (
72
+ typeof error === "object" &&
73
+ error !== null &&
74
+ "code" in error &&
75
+ (error as { code?: unknown }).code === "P2002"
76
+ );
77
+ }
78
+
79
+ /**
80
+ * TOFU-binds the operator Membership for a single-tenant deployment (spec
81
+ * D1–D4) — the replacement for the deleted user-agnostic `owner` grant. The
82
+ * Membership is created with a fixed, well-known id
83
+ * (`OPERATOR_MEMBERSHIP_ID`); the `Membership.id` primary-key uniqueness —
84
+ * not a read-then-write "is this the first user?" guard — enforces exactly
85
+ * one owner under a concurrent first-login race. The losing caller's
86
+ * `create` raises `P2002`, swallowed here: that caller is then unbound and
87
+ * denied by the `membership.findUnique` the caller of this function runs
88
+ * next.
89
+ *
90
+ * Idempotent + at-most-once: a caller who already holds the Membership (a
91
+ * repeat call, or the deployment's steady state after the first bind) hits
92
+ * the same `P2002` path and is a no-op. Reachable ONLY from
93
+ * `resolveDefaultTenant`, itself gated by the
94
+ * `count === 1 && default-workspace-exists` check — unreachable from any
95
+ * multi-tenant state (`count > 1`).
96
+ *
97
+ * When `AILK_OPERATOR_EMAIL` is set (spec D4 — trimmed + lowercased before
98
+ * comparison), the bind is attempted ONLY for the caller whose VERIFIED
99
+ * `User.email` matches it (#5701 AC-2 — the read goes through
100
+ * `findVerifiedEmail`, so an unverified address is refused here and not only
101
+ * at the base-build entry above); every other caller returns without writing
102
+ * and is denied downstream. An empty/whitespace-only `AILK_OPERATOR_EMAIL` normalizes to
103
+ * `""`, which is falsy — treated as unset (pure TOFU), so a blank pin can
104
+ * never "match" a blank `User.email`. When unset, the bind is pure TOFU —
105
+ * first caller wins.
106
+ */
107
+ async function bindOperatorMembership(userId: Id<"user">): Promise<void> {
108
+ const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
109
+ if (pinnedEmail) {
110
+ // The verified-email read is `services/verified-email.ts` (#5701 D4) — the
111
+ // same one `resolveDefaultTenantForOperator` uses. It returns the address
112
+ // only when `emailVerified === true`, so an address that merely CLAIMS to
113
+ // be the pin can no longer mint `OPERATOR_MEMBERSHIP_ID`. Both refusals
114
+ // (`user_not_found`, `email_unverified`) take the branch's existing early
115
+ // return — the caller is then unbound and denied by the
116
+ // `membership.findUnique` that follows.
117
+ const verified = await findVerifiedEmail(userId);
118
+ if (!verified.ok) {
119
+ return;
120
+ }
121
+ if (verified.value.email.trim().toLowerCase() !== pinnedEmail) {
122
+ return;
123
+ }
124
+ }
125
+ try {
126
+ await prisma.membership.create({
127
+ data: {
128
+ id: OPERATOR_MEMBERSHIP_ID,
129
+ organizationId: DEFAULT_ORG_ID,
130
+ userId,
131
+ role: "owner",
132
+ },
133
+ });
134
+ } catch (error) {
135
+ if (!isUniqueViolation(error)) {
136
+ throw error;
137
+ }
138
+ // Another caller already holds OPERATOR_MEMBERSHIP_ID (the TOFU race
139
+ // winner, or a prior deployment run) — swallow and fall through.
140
+ }
141
+ }
142
+
143
+ /**
144
+ * The BASE-BUILD entry point: `resolveDefaultTenant` narrowed to the caller
145
+ * whose `User.email` matches `AILK_OPERATOR_EMAIL`, denying everyone when the
146
+ * pin is unset (#5693 security review, High finding).
147
+ *
148
+ * WHY THE BASE PATH NEEDS THIS AND THE MULTI-TENANT PATH DOES NOT. Registering
149
+ * the review surface on a module-less build made two things reachable together
150
+ * that were previously unreachable there: this resolution's TOFU operator bind,
151
+ * and `services/waitlist-dashboard.ts`'s `isPlatformOperator`, which grants
152
+ * `{kind: "all"}` — every site's rows, unfiltered — to an `owner` of the
153
+ * sentinel default Organization whenever `AILK_OPERATOR_EMAIL` is merely SET.
154
+ * That predicate never compares the caller to the pin; the pin is an on/off
155
+ * switch for it.
156
+ *
157
+ * Unpinned TOFU plus that predicate is an escalation. The site's auth
158
+ * auto-provisions a `User` on first login, so on a deployment with the pin
159
+ * unset the FIRST authenticated caller to touch a review route — any public
160
+ * respondent, not necessarily the operator — wins `OPERATOR_MEMBERSHIP_ID`.
161
+ * The real operator is then denied (their `create` raises `P2002`, swallowed),
162
+ * finds an empty dashboard, and sets `AILK_OPERATOR_EMAIL` to fix it — which
163
+ * flips the squatter, not the operator, to `{kind: "all"}`.
164
+ *
165
+ * So the base path requires the pin and requires the caller to BE it. Both
166
+ * halves matter: requiring the pin stops an unpinned race from seating anyone,
167
+ * and re-checking the caller's email on every request denies a squatter who
168
+ * was already bound before this code shipped — a Membership row alone is no
169
+ * longer enough. `bindOperatorMembership` below performs its own email check,
170
+ * but only at bind time and only for the bind; this is the one that gates the
171
+ * grant.
172
+ *
173
+ * The multi-tenant module is unaffected: `resolveActiveTenant` keeps calling
174
+ * `resolveDefaultTenant` directly, on exactly the branch it always called it
175
+ * on. This wrapper is reached only from `middleware/default-tenant.ts`, which
176
+ * only ever runs on a build without that module.
177
+ *
178
+ * `docs/specs/single-tenant-owner-pin-spec.md` D5 already recommended pinning;
179
+ * on this path it is mandatory rather than advisory, and unset fails closed.
180
+ *
181
+ * ONE KNOWN IMPRECISION, deliberate. `User.email` is `@unique` in Postgres,
182
+ * which is byte-exact, while this comparison lowercases both sides — so two
183
+ * distinct rows (`Operator@x.com` and `operator@x.com`) could both satisfy one
184
+ * pin. It is not reachable today: `apps/web/auth.ts` configures social
185
+ * providers only, so the address is provider-asserted and normalized rather
186
+ * than caller-chosen. It becomes reachable the moment a credential provider is
187
+ * enabled, and the durable fix is case-insensitive email uniqueness in the
188
+ * schema (citext, or lowercase-at-write) so "matches the pin" and "is unique"
189
+ * agree. Matching case-sensitively here instead would be worse: it would lock
190
+ * out an operator who typed their own address with different capitalisation.
191
+ */
192
+ export async function resolveDefaultTenantForOperator(
193
+ userId: Id<"user">,
194
+ ): Promise<TenantContext | null> {
195
+ const pinnedEmail = process.env.AILK_OPERATOR_EMAIL?.trim().toLowerCase();
196
+ if (!pinnedEmail) {
197
+ return null;
198
+ }
199
+ // The verified-email read is `services/verified-email.ts` (#5701 D4) — the
200
+ // one this and the four other email-keyed paths share. It returns the
201
+ // address only when `emailVerified === true`, which is what makes a match
202
+ // against the pin a proven claim rather than an asserted one.
203
+ const verified = await findVerifiedEmail(userId);
204
+ if (!verified.ok) {
205
+ return null;
206
+ }
207
+ if (verified.value.email.trim().toLowerCase() !== pinnedEmail) {
208
+ return null;
209
+ }
210
+ return resolveDefaultTenant(userId);
211
+ }
212
+
213
+ /**
214
+ * The DEPLOYMENT half of the default resolution, extracted so it exists once
215
+ * (#5852 D2). Returns the sole, well-known default Workspace row, or `null`
216
+ * the instant the deployment is not unambiguously one-workspace — a second
217
+ * Workspace exists, none does, or the sole one is not the seeded default.
218
+ *
219
+ * Carries no authentication: it answers "is this deployment one-workspace, and
220
+ * which workspace is it", nothing about the caller. `resolveDefaultTenant`
221
+ * adds the authentication half on top; `services/public-site-resolver.ts`
222
+ * (unauthenticated public reads) uses the deployment half alone. One
223
+ * predicate, reached two ways — never two predicates.
224
+ *
225
+ * The predicate itself is `@working-theory/database/tenancy`'s
226
+ * `resolveDefaultWorkspaceRow` (#5876 spec D2/D3) — this is now a thin
227
+ * pass-through of `apps/api`'s own `prisma` singleton, kept as a named local
228
+ * function (rather than re-exporting the shared one directly) so every
229
+ * existing caller — this file's own `resolveDefaultWorkspaceId` and
230
+ * `resolveDefaultTenant`, and `services/public-site-resolver.ts`'s dynamic
231
+ * import of the wrapper — is untouched. There is exactly one implementation
232
+ * of the one-workspace predicate in the repository (NFR-7); apps/mcp's own
233
+ * resolution (`apps/mcp/src/lib/public-workspace.ts`) delegates to the same
234
+ * shared export.
235
+ */
236
+ async function resolveDefaultWorkspaceRow(): Promise<{
237
+ id: string;
238
+ organizationId: string;
239
+ } | null> {
240
+ return resolveDefaultWorkspaceRowShared(prisma);
241
+ }
242
+
243
+ /**
244
+ * The one-workspace deployment predicate, as a workspace id (#5852 D2).
245
+ * `null` means the deployment is not unambiguously one-workspace, and every
246
+ * caller fails closed on it.
247
+ */
248
+ export async function resolveDefaultWorkspaceId(): Promise<Id<"workspace"> | null> {
249
+ const workspace = await resolveDefaultWorkspaceRow();
250
+ return workspace ? (workspace.id as Id<"workspace">) : null;
251
+ }
252
+
253
+ /**
254
+ * Resolves the default single-tenant scope when the deployment is
255
+ * unambiguously single-tenant — the default Workspace (`DEFAULT_WORKSPACE_ID`)
256
+ * exists AND it is the ONLY Workspace (`workspace.count() === 1`), the exact
257
+ * mirror of the Phase-1 provision guard's `count === 0` check (spec D2).
258
+ * Returns `null` (never grants) the instant a second Workspace exists, or the
259
+ * sole Workspace is not the well-known default — the caller falls through to
260
+ * its own default-deny in that case.
261
+ *
262
+ * Converges on the SAME Membership-based authz the multi-tenant path uses
263
+ * (spec D1 — the fix): attempts to TOFU-bind the caller as operator
264
+ * (`bindOperatorMembership`, a no-op once an operator is already bound or
265
+ * the caller doesn't match the optional env pin), then runs the real
266
+ * `membership.findUnique` check. A caller holding no Membership on the
267
+ * default Org — including every caller after the first once TOFU has bound
268
+ * someone else — resolves `null` here, and the caller denies. There is no
269
+ * user-agnostic `owner` grant.
270
+ *
271
+ * Formerly `resolveSingleTenantDefault` in `services/tenant-context.ts`
272
+ * (#3631); moved here verbatim by #5677 so the base build can reach it.
273
+ */
274
+ export async function resolveDefaultTenant(
275
+ userId: Id<"user">,
276
+ ): Promise<TenantContext | null> {
277
+ const workspace = await resolveDefaultWorkspaceRow();
278
+ if (!workspace) {
279
+ return null;
280
+ }
281
+ await bindOperatorMembership(userId);
282
+ const membership = await prisma.membership.findUnique({
283
+ where: {
284
+ organizationId_userId: {
285
+ organizationId: workspace.organizationId,
286
+ userId,
287
+ },
288
+ },
289
+ select: { role: true },
290
+ });
291
+ if (!membership) {
292
+ return null;
293
+ }
294
+ return {
295
+ userId,
296
+ organizationId: workspace.organizationId as Id<"organization">,
297
+ workspaceId: workspace.id as Id<"workspace">,
298
+ role: membership.role as MembershipRole,
299
+ };
300
+ }
@@ -45,13 +45,30 @@
45
45
  * PII boundary: logs carry only `{ siteId, flowSlug, sessionId, outcome }` —
46
46
  * never email/asset-key/copy values (mirrors flow-engine.ts's + abandon-
47
47
  * email.ts's boundary).
48
+ *
49
+ * #6310 D1/D2/D4/D5/D15 — a non-`playbook:` `assetKey` now resolves against
50
+ * the workspace-scoped `Asset` store FIRST, via the named boundary
51
+ * `lib/deliverable-asset-store.ts` (never a raw `prisma.asset` call here —
52
+ * that access is banned by architecture.yaml's tenant-scope
53
+ * `forbidden_pattern`), falling back to the LEGACY `DeliverableAsset` store
54
+ * on a miss (D3 — existing rows keep delivering, unmigrated). The workspace
55
+ * is read from the magnet's own `FlowDefinition.workspaceId`, ONE
56
+ * `findUnique` issued only on this branch (never for a `playbook:` key, and
57
+ * never before the `no_entry` exit) — a `null`/absent value skips the
58
+ * `Asset` lookup entirely and falls straight to the legacy arm, never
59
+ * guessing a workspace (D5, mirroring #6155 D3's posture). The grant is
60
+ * minted with `workspaceId` ONLY on the `Asset` arm — the same value that
61
+ * authorized the resolution — restoring the download route's serve-time
62
+ * tenancy re-check for this new grant class (D15).
48
63
  */
49
64
 
50
65
  import { sendDeliverableEmail } from "@working-theory/email";
51
66
  import { logger } from "@working-theory/observability";
52
67
  import { flowConfigSchema, type FlowConfig } from "@working-theory/validation";
53
68
 
69
+ import { findDeliverableAssetIdByKey } from "../lib/deliverable-asset-store.js";
54
70
  import { prisma } from "../lib/prisma.js";
71
+ import { resolveWorkspaceIdForAlias } from "../lib/site.js";
55
72
 
56
73
  // ─── Types ────────────────────────────────────────────────────────────────────
57
74
 
@@ -194,6 +211,15 @@ async function stampOutcome(
194
211
  now: Date,
195
212
  ): Promise<void> {
196
213
  try {
214
+ // fence: global-key — `DeliverableGrant.id`, the capability token itself
215
+ // (#3961 D5: the grant IS the token). Every caller reaches this line
216
+ // inside an already-scoped frame — `fulfillDeliverable` has just MINTED
217
+ // this id under the session's workspace, and `resendDeliverableGrant` has
218
+ // just read the row back by the same opaque id — so the id is the row's
219
+ // own primary key, not a tenant selector, and a `workspaceId` term here
220
+ // could only re-state what the mint already fenced. It would also stop
221
+ // stamping LEGACY grants, whose `workspaceId` is NULL by the #6254 § 10.1
222
+ // Q2 carve-out, turning a post-mint audit stamp into a silent no-op.
197
223
  await prisma.deliverableGrant.update({
198
224
  where: { id: grantId },
199
225
  data: { sendStatus: outcome, sendAt: now },
@@ -312,9 +338,17 @@ export async function fulfillDeliverable(
312
338
  const playbookKey = playbookExperimentKey(entry.assetKey);
313
339
 
314
340
  let assetId: string;
341
+ // #6310 D15 — written ONLY on the Asset arm; stays undefined (no column
342
+ // written) for a playbook: or legacy dasset_ grant.
343
+ let grantWorkspaceId: string | undefined;
344
+
315
345
  if (playbookKey !== undefined) {
346
+ // #6257 — `siteId` is the session's own, resolved by the route that
347
+ // opened it (#6254/#6374 AC-7), so `workspaceId` rides beside it: a lead
348
+ // id and an experiment key alone would select a row whose tenant the
349
+ // query never stated.
316
350
  const instance = await prisma.playbookInstance.findFirst({
317
- where: { siteId, leadId, experimentKey: playbookKey },
351
+ where: { siteId, workspaceId: siteId, leadId, experimentKey: playbookKey },
318
352
  select: { id: true },
319
353
  });
320
354
  if (!instance) {
@@ -323,24 +357,75 @@ export async function fulfillDeliverable(
323
357
  }
324
358
  assetId = entry.assetKey;
325
359
  } else {
326
- // A missing row (config references an un-uploaded key) → same
327
- // skip-and-log posture (D3).
328
- const asset = (await prisma.deliverableAsset.findUnique({
329
- where: { siteId_key: { siteId, key: entry.assetKey } },
330
- })) as DeliverableAssetRow | null;
331
-
332
- if (!asset) {
333
- logOutcome(logCtx, "asset_missing");
334
- return;
360
+ // #6310 D4 — resolve the workspace from the magnet's own live
361
+ // FlowDefinition, ONE query, issued only on this branch. D5 — a null or
362
+ // absent workspaceId skips the Asset lookup entirely; it is never
363
+ // guessed.
364
+ const definition = (await prisma.flowDefinition.findUnique({
365
+ where: { siteId_slug: { siteId, slug: flowSlug } },
366
+ select: { workspaceId: true },
367
+ })) as { workspaceId: string | null } | null;
368
+ const workspaceId = definition?.workspaceId ?? null;
369
+
370
+ const resolvedAssetId =
371
+ workspaceId !== null
372
+ ? await findDeliverableAssetIdByKey(workspaceId, entry.assetKey)
373
+ : null;
374
+
375
+ if (resolvedAssetId !== null && workspaceId !== null) {
376
+ // #6310 D2 — Asset resolves first; D15 — the grant carries the SAME
377
+ // workspaceId that authorized this resolution.
378
+ assetId = resolvedAssetId;
379
+ grantWorkspaceId = workspaceId;
380
+ } else {
381
+ // #6310 D2/D3 — legacy fallback. A missing row (config references an
382
+ // un-uploaded key) → same skip-and-log posture as before.
383
+ // #6257 — the LEGACY store's read, scoped by the same resolved
384
+ // workspace id the `Asset` arm above already resolves through
385
+ // `lib/deliverable-asset-store.ts`. The compound `[siteId, key]` names
386
+ // one row; `workspaceId` is what makes the tenant the QUERY's statement.
387
+ const asset = (await prisma.deliverableAsset.findUnique({
388
+ where: { siteId_key: { siteId, key: entry.assetKey }, workspaceId: siteId },
389
+ })) as DeliverableAssetRow | null;
390
+
391
+ if (!asset) {
392
+ logOutcome(logCtx, "asset_missing");
393
+ return;
394
+ }
395
+ assetId = asset.id;
335
396
  }
336
- assetId = asset.id;
337
397
  }
338
398
 
339
399
  // D5: the grant IS the token — minted before the send is attempted so a
340
400
  // later email-config gap or send failure never wastes it (a later
341
401
  // manual/operator resend can reuse the same grant id).
342
402
  const grant = (await prisma.deliverableGrant.create({
343
- data: { siteId, assetId, leadId, sessionId },
403
+ data: {
404
+ siteId,
405
+ assetId,
406
+ leadId,
407
+ sessionId,
408
+ // #6374 D4 / AC-8 — the SECOND DeliverableGrant mint site. The column
409
+ // stays NULLABLE for LEGACY rows (NULL is the serve path's fail-closed
410
+ // sentinel, #6254 § 10.1 Q2) and the migration touches none of them —
411
+ // but a grant minted HERE always knows its workspace, so it is never
412
+ // left to fail closed.
413
+ //
414
+ // `grantWorkspaceId` is #6310 D15's value, resolved from the magnet's
415
+ // own FlowDefinition on the `asset_` arm; that read is scoped by
416
+ // `siteId`, and the keystone's `flow_definitions_workspace_site_agree`
417
+ // CHECK makes `workspaceId = siteId` on that row, so the two are the
418
+ // SAME value and the `??` is a fallback for the legacy `dasset_` and
419
+ // virtual `playbook:` arms rather than a choice between two scopes.
420
+ // The new `deliverable_grants_workspace_site_agree` CHECK would reject
421
+ // a disagreement loudly either way.
422
+ //
423
+ // This does NOT change which bytes the download route serves: that
424
+ // route branches on the `assetId` PREFIX, and consults
425
+ // `grant.workspaceId` only on the `asset_` arm, where this value is
426
+ // what it already was.
427
+ workspaceId: grantWorkspaceId ?? siteId,
428
+ },
344
429
  select: { id: true },
345
430
  })) as { id: string };
346
431
 
@@ -400,6 +485,13 @@ type FlowSessionConfigRow = {
400
485
  export async function resendDeliverableGrant(
401
486
  grantId: string,
402
487
  ): Promise<ResendGrantResult> {
488
+ // fence: global-key — `DeliverableGrant.id`, the capability token itself
489
+ // (#3961 D5). This is the resolver: the operator hands the
490
+ // `deliverable:resend` CLI one opaque grant id and nothing else, and the
491
+ // WORKSPACE is what this read produces (`grant.siteId`, carried into
492
+ // `sendAndStamp` below), so a tenant term here would have to come from the
493
+ // answer it is computing. Holding the id is the capability, exactly as it
494
+ // is for the download route this resend re-sends a link to.
403
495
  const grant = (await prisma.deliverableGrant.findUnique({
404
496
  where: { id: grantId },
405
497
  select: { id: true, siteId: true, leadId: true, sessionId: true },
@@ -514,9 +606,45 @@ type DeliverableGrantListRow = {
514
606
  export async function listSendlessGrants(
515
607
  opts: ListSendlessGrantsOptions,
516
608
  ): Promise<SendlessGrantRow[]> {
609
+ // #6257 — `opts.siteId` is the one value in this service that arrives as a
610
+ // RAW ALIAS: the `deliverable:resend` CLI passes `--site` through verbatim.
611
+ // It is resolved here, once, the way `dunning-policy.ts` resolves its own
612
+ // parameter — an alias that names no workspace lists nothing rather than
613
+ // falling back to the unscoped read it used to make.
614
+ const workspaceId = await resolveWorkspaceIdForAlias(opts.siteId);
615
+ if (workspaceId === null) {
616
+ logger.warn(
617
+ { siteId: opts.siteId, outcome: "site_unresolved" },
618
+ "[deliverable-fulfillment] --site names no workspace — listing nothing",
619
+ );
620
+ return [];
621
+ }
622
+
623
+ // #6257 — LEGACY grants are NOT in this list, and the narrowing is stated
624
+ // rather than left for an operator to discover. Two terms exclude them: the
625
+ // `workspaceId` filter below (their column is NULL by the #6254 § 10.1 Q2
626
+ // carve-out) and the resolved `siteId` (the keystone migration deliberately
627
+ // does NOT normalize `deliverable_grants.site_id` — migration.sql:1059 — so
628
+ // a legacy row on a `"default"`-aliased deployment still carries the alias).
629
+ //
630
+ // This is a genuine TIGHTENING beyond the serve path, not parity with it:
631
+ // `routes/deliverables/index.ts` consults `grant.workspaceId` only on the
632
+ // `asset_` arm, so a legacy `dasset_` or `playbook:` grant still downloads.
633
+ // The fence forces it — AC-2 forbids keying this read on the raw alias the
634
+ // CLI was given, and a legacy row carries no workspace key to resolve — so
635
+ // the list is narrower by construction and the recovery path for such a row
636
+ // is `deliverable:resend --grant <id>`, which is unaffected.
637
+ logger.info(
638
+ { siteId: workspaceId, outcome: "legacy_grants_excluded" },
639
+ "[deliverable-fulfillment] grants with no workspace key are NOT listed — resend one by id with --grant",
640
+ );
641
+
517
642
  const rows = (await prisma.deliverableGrant.findMany({
518
643
  where: {
519
- siteId: opts.siteId,
644
+ siteId: workspaceId,
645
+ // #6257 — the fence term. See the note above for what it excludes and
646
+ // why that is a tightening rather than serve-path parity.
647
+ workspaceId,
520
648
  sendStatus: null,
521
649
  ...(opts.since !== undefined && { createdAt: { gte: opts.since } }),
522
650
  },