create-ailk 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/cli.js +54 -5
  5. package/dist/copy.d.ts +14 -0
  6. package/dist/copy.js +38 -1
  7. package/dist/lib/api-boot-assertion.d.ts +46 -0
  8. package/dist/lib/api-boot-assertion.js +70 -0
  9. package/dist/lib/extract-module-bundle.d.ts +20 -1
  10. package/dist/lib/extract-module-bundle.js +64 -5
  11. package/dist/lib/import-graph.d.ts +29 -0
  12. package/dist/lib/import-graph.js +140 -0
  13. package/dist/lib/module-license-gate.d.ts +6 -1
  14. package/dist/lib/module-license-gate.js +6 -1
  15. package/dist/lib/pro-callout.js +1 -1
  16. package/dist/module-architecture.d.ts +1 -0
  17. package/dist/module-architecture.js +253 -127
  18. package/dist/modules/commerce/0.6.0.tar.gz +0 -0
  19. package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
  20. package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
  21. package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
  22. package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
  23. package/dist/modules/workspace/0.6.0.tar.gz +0 -0
  24. package/dist/programmatic.js +14 -10
  25. package/dist/surfaces.js +105 -7
  26. package/dist/template-manifest.d.ts +33 -0
  27. package/dist/template-manifest.js +99 -16
  28. package/dist/template-names.d.ts +1 -1
  29. package/dist/template-names.js +1 -0
  30. package/package.json +3 -2
  31. package/site-templates/marketing-site/DESIGN.md +148 -0
  32. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  34. package/site-templates/marketing-site/ailk-template.json +1 -1
  35. package/site-templates/marketing-site-free/DESIGN.md +148 -0
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  37. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  38. package/site-templates/minimalism-site/DESIGN.md +148 -0
  39. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  40. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  41. package/site-templates/minimalism-site/ailk-template.json +2 -1
  42. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  43. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  44. package/site-templates/saas-product/DESIGN.md +148 -0
  45. package/site-templates/simple-service/DESIGN.md +148 -0
  46. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  47. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  48. package/site-templates/simple-service/ailk-template.json +10 -0
  49. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  50. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  51. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  52. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  53. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  54. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  55. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  56. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  57. package/site-templates/simple-service/provenance.json +4 -0
  58. package/templates/.env.example +72 -7
  59. package/templates/CLAUDE.md +2 -3
  60. package/templates/DESIGN.md +155 -0
  61. package/templates/README.md +42 -8
  62. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  63. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  64. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  65. package/templates/apps/api/.env.example +13 -0
  66. package/templates/apps/api/CLAUDE.md +26 -2
  67. package/templates/apps/api/jest.config.cjs +3 -0
  68. package/templates/apps/api/package.json +1 -0
  69. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  70. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  71. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  72. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  73. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  74. package/templates/apps/api/src/config/index.ts +62 -0
  75. package/templates/apps/api/src/config/modules.ts +7 -3
  76. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  77. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  78. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  79. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  80. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  81. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  82. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  83. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  84. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  85. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  86. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  87. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  88. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  89. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  90. package/templates/apps/api/src/lib/site.ts +208 -9
  91. package/templates/apps/api/src/lib/stripe.ts +66 -0
  92. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  93. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  94. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  95. package/templates/apps/api/src/openapi/spec.ts +543 -2
  96. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  97. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  98. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  99. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  100. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  101. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  102. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  103. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  104. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  105. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  106. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  107. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  108. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  109. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  110. package/templates/apps/api/src/routes/content/index.ts +13 -73
  111. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  112. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  113. package/templates/apps/api/src/routes/content/search.ts +129 -0
  114. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  115. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  116. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  117. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  118. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  119. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  120. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  121. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  122. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  123. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  124. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  125. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  126. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  127. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  128. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  129. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  130. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  131. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  132. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  133. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  134. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  135. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  136. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  137. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  138. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  139. package/templates/apps/api/src/server.ts +439 -49
  140. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  141. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  142. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  143. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  144. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  145. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  146. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  147. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  148. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  149. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  150. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  151. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  152. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  153. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  154. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  155. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  156. package/templates/apps/api/src/services/audit-log.ts +668 -0
  157. package/templates/apps/api/src/services/consent.ts +18 -0
  158. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  159. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  160. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  161. package/templates/apps/api/src/services/entitlements.ts +80 -0
  162. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  163. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  164. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  165. package/templates/apps/api/src/services/listing-config.ts +27 -9
  166. package/templates/apps/api/src/services/project-listings.ts +201 -6
  167. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  168. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  169. package/templates/apps/api/src/services/revalidation.ts +173 -0
  170. package/templates/apps/api/src/services/verified-email.ts +71 -0
  171. package/templates/apps/api/src/vercel-handler.ts +75 -35
  172. package/templates/apps/mcp/.env.example +17 -0
  173. package/templates/apps/mcp/CLAUDE.md +57 -24
  174. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  175. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  176. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  177. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  178. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  179. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  180. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  181. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  182. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  183. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  184. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  185. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  186. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  187. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  188. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  189. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  190. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  191. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  192. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  193. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  194. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  195. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  196. package/templates/apps/mcp/jest.config.cjs +13 -0
  197. package/templates/apps/mcp/package.json +2 -0
  198. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  199. package/templates/apps/mcp/src/config/modules.ts +10 -5
  200. package/templates/apps/mcp/src/http-transport.ts +102 -29
  201. package/templates/apps/mcp/src/index.ts +73 -11
  202. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  203. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  204. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  205. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  206. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  207. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  208. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  209. package/templates/apps/mcp/src/server.ts +59 -35
  210. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  211. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  212. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  213. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  214. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  215. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  216. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  217. package/templates/apps/mcp/src/tools/index.ts +6 -4
  218. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  219. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  220. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  221. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  222. package/templates/apps/web/.env.example +71 -5
  223. package/templates/apps/web/CLAUDE.md +44 -0
  224. package/templates/apps/web/PRODUCT.md +86 -0
  225. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  226. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  227. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  228. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  229. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  230. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  231. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  232. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  233. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  234. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  235. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  236. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  237. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  238. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  239. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  240. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  241. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  242. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  243. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  244. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  245. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  246. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  247. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  248. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  249. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  250. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  251. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  252. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  253. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  254. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  255. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  256. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  257. package/templates/apps/web/auth.ts +407 -8
  258. package/templates/apps/web/jest.config.cjs +8 -4
  259. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  260. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  261. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  262. package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
  263. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  264. package/templates/apps/web/lib/auth-email.ts +318 -0
  265. package/templates/apps/web/lib/auth-env.ts +85 -0
  266. package/templates/apps/web/lib/auth-routes.ts +55 -0
  267. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  268. package/templates/apps/web/lib/site-brand.tsx +4 -8
  269. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  270. package/templates/apps/web/messages/en.json +187 -0
  271. package/templates/apps/web/middleware.ts +1 -1
  272. package/templates/apps/web/package.json +2 -1
  273. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  274. package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
  275. package/templates/content/_site.mdx +18 -3
  276. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  277. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  278. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  279. package/templates/database/CHANGELOG.md +149 -0
  280. package/templates/database/README.md +10 -3
  281. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  282. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  283. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  284. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  285. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  286. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  287. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  288. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  289. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  290. package/templates/database/__tests__/tenancy.test.ts +141 -0
  291. package/templates/database/config/schema.prisma +79 -2
  292. package/templates/database/content/schema.prisma +87 -11
  293. package/templates/database/inbox/schema.prisma +452 -9
  294. package/templates/database/jest.config.cjs +5 -3
  295. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  296. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  297. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  298. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  299. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  300. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  301. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  302. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  303. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  304. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  305. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  306. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  307. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  308. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  309. package/templates/database/package.json +3 -2
  310. package/templates/database/tenancy.ts +86 -1
  311. package/templates/database/tsconfig.json +1 -0
  312. package/versions.json +34 -34
  313. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  314. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  315. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  316. package/templates/apps/api/src/routes/content/create.ts +0 -115
  317. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  318. package/templates/apps/api/src/routes/content/update.ts +0 -112
  319. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  320. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  321. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  322. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  323. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  324. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  325. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  326. package/templates/apps/web/public/logo-dark.svg +0 -21
  327. package/templates/apps/web/public/logo-light.svg +0 -21
  328. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  329. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -27,7 +27,14 @@
27
27
  * idempotency reader) + callback.findMany (booked-call suppression) +
28
28
  * waitlistScore.findMany (tier lookup) — used by the #4732
29
29
  * waitlist-followup sweep service tests
30
- * tenancy models (workspace, membership) — used by tenant-context service tests (#3627)
30
+ * tenancy models (workspace, membership) — used by tenant-context service tests (#3627).
31
+ * workspace.findUnique carries NO default (#6335) — see its own comment
32
+ * below. A suite whose route resolves a workspace states the shape it
33
+ * expects through `mockWorkspaceExists()` / `mockNoWorkspace()`, exported
34
+ * at the foot of this file
35
+ * artifact models (artifact, artifactVersion) — the database content
36
+ * adapter's read surface, used by the public-site-resolver + content route
37
+ * tests (#5852)
31
38
  * membership.findMany + lead.findMany/updateMany — used by lead-promotion
32
39
  * service tests (#3586); lead-promotion.ts never queries membership/workspace
33
40
  * directly (it resolves workspaces via tenant-context.ts's listTenancy), so
@@ -49,6 +56,10 @@
49
56
  * #3739 M2 — was `findMany` + a JS accumulation loop). Same not-yet-
50
57
  * generated-client situation; `usage-aggregation.ts` casts `prisma` to its
51
58
  * own structural delegate type, and this mock's runtime shape matches it.
59
+ *
60
+ * userData.create/updateMany (auth) + siteSetting.findUnique (config) — the
61
+ * dunning record's compare-and-swap write and the per-tenant grace-policy
62
+ * read (#6034), used by dunning-policy.test.ts and stripe-dunning.test.ts.
52
63
  */
53
64
 
54
65
  export const prisma = {
@@ -68,6 +79,13 @@ export const prisma = {
68
79
  findUnique: jest.fn(),
69
80
  upsert: jest.fn(),
70
81
  deleteMany: jest.fn(),
82
+ // `create` / `updateMany` — the dunning record's compare-and-swap write
83
+ // (#6034 § D6): the webhook reads `{value, updatedAt}` then either
84
+ // `create`s a fresh row or `updateMany`s guarded on BOTH observed
85
+ // columns, so a concurrent writer makes the predicate miss (`count: 0`)
86
+ // rather than silently losing a revocation.
87
+ create: jest.fn(),
88
+ updateMany: jest.fn(),
71
89
  },
72
90
  // ── Tenancy namespace (Organization, Workspace, Membership) ──────────────
73
91
  organization: {
@@ -84,6 +102,29 @@ export const prisma = {
84
102
  updateMany: jest.fn(),
85
103
  },
86
104
  workspace: {
105
+ // #6335 — NO default implementation, deliberately. Until this issue the
106
+ // delegate carried a PERMISSIVE one (#6203): it resolved ANY requested
107
+ // id as an existing workspace with a sentinel-shaped organizationId, so
108
+ // every tenancy resolver in a route test saw "this workspace exists" no
109
+ // matter what the test had set up. That is DEF-014 — a fixture that
110
+ // fabricates the condition under test: a route that should have failed
111
+ // closed passed, and every isolation-shaped test written against this
112
+ // mock inherited a fake success.
113
+ //
114
+ // A bare `jest.fn()` resolves `undefined`, which every resolver in
115
+ // `apps/api/src/lib/site.ts` reads as "no such workspace" and rejects —
116
+ // so a suite that needs a workspace to resolve must SAY SO, keyed on the
117
+ // id it actually expects to be asked for:
118
+ //
119
+ // mockWorkspaceExists(prisma, SITE_ID) // only SITE_ID resolves
120
+ // mockSentinelWorkspaceExists(prisma) // the "default" alias path
121
+ // mockNoWorkspace(prisma) // nothing resolves
122
+ //
123
+ // Keying on the requested id is the load-bearing part.
124
+ // `resolveWorkspaceIdForAlias` returns the ALIAS on mere truthiness of
125
+ // the row (`site.ts`) — it never reads the row's own id — so a mock that
126
+ // resolves unconditionally is permissive by accident even when it hands
127
+ // back a specific-looking row.
87
128
  findUnique: jest.fn(),
88
129
  count: jest.fn(),
89
130
  },
@@ -92,6 +133,18 @@ export const prisma = {
92
133
  findMany: jest.fn(),
93
134
  create: jest.fn(),
94
135
  },
136
+ // ── Artifact store (the database content adapter's read surface, #5852) ──
137
+ // Only the two readers `ArtifactPrismaClient` declares for reads
138
+ // (packages/content-adapters/src/database/adapter.ts): the public content
139
+ // routes never write, so no `create`/`updateMany`/`deleteMany` is faked here.
140
+ artifact: {
141
+ findFirst: jest.fn(),
142
+ findMany: jest.fn(),
143
+ },
144
+ artifactVersion: {
145
+ findFirst: jest.fn(),
146
+ findMany: jest.fn(),
147
+ },
95
148
  // ── Config namespace (per-workspace site config — #3641/#3634) ───────────
96
149
  workspaceTheme: {
97
150
  findUnique: jest.fn(),
@@ -127,9 +180,16 @@ export const prisma = {
127
180
  // `findMany` — the #4732 follow-up sweep's booked-call suppression check
128
181
  // (D2: a site's Callback rows fetched once per sweep run, filtered in
129
182
  // application code for `payload.email` matches).
183
+ // findFirst / update / updateMany joined for the #5970 request-and-confirm
184
+ // lifecycle: the credential mint and the confirmation claim are both guarded
185
+ // `updateMany`s, the GET resolves through `findFirst`, and the Stripe
186
+ // artifact id lands via `update`.
130
187
  callback: {
131
188
  create: jest.fn(),
189
+ findFirst: jest.fn(),
132
190
  findMany: jest.fn(),
191
+ update: jest.fn(),
192
+ updateMany: jest.fn(),
133
193
  },
134
194
  // ── Inbox namespace (FlowDefinition, FlowSession — #3956) ────────────────
135
195
  // findMany on both models — used by the abandon-email sweep (#3962 D4),
@@ -137,6 +197,10 @@ export const prisma = {
137
197
  // policy) and every candidate FlowSession per definition.
138
198
  flowDefinition: {
139
199
  findUnique: jest.fn(),
200
+ // #6314 — `startProjectListingDraft` resolves the capturing listicle
201
+ // magnet through `findFirst` (slug + siteId + workspaceId + kind), so the
202
+ // delegate needs it here as well as `findUnique`.
203
+ findFirst: jest.fn(),
140
204
  findMany: jest.fn(),
141
205
  upsert: jest.fn(),
142
206
  },
@@ -156,13 +220,21 @@ export const prisma = {
156
220
  },
157
221
  // `update` — the send-outcome stamp (#3998 D1). `findMany` — the
158
222
  // deliverable:resend CLI's list mode (#3998 D4), selecting on
159
- // `sendStatus: null`.
223
+ // `sendStatus: null`. `workspaceId` (#6310 D15) rides `create`'s existing
224
+ // `data` shape — no new delegate method.
160
225
  deliverableGrant: {
161
226
  create: jest.fn(),
162
227
  findUnique: jest.fn(),
163
228
  update: jest.fn(),
164
229
  findMany: jest.fn(),
165
230
  },
231
+ // ── Content namespace (Asset — #5854/#6093, extended #6310 D16) ──────────
232
+ // `findFirst` — the ONLY method `lib/asset-store.ts`'s public read and
233
+ // `lib/deliverable-asset-store.ts`'s two scoped reads need. No write, no
234
+ // `findMany`, no `findUnique` — neither boundary enumerates or mutates.
235
+ asset: {
236
+ findFirst: jest.fn(),
237
+ },
166
238
  // ── Inbox namespace (FlowCheckout — #4837 D4) ─────────────────────────────
167
239
  // `create` — recordCheckout's one write; `update` — attachStripeSession;
168
240
  // `updateMany` — the webhook's guarded status stamp (#4838); `findUnique` /
@@ -182,6 +254,19 @@ export const prisma = {
182
254
  siteListingConfig: {
183
255
  findUnique: jest.fn(),
184
256
  },
257
+ // ── Config namespace (SiteSetting — #6034 § D4) ──────────────────────────
258
+ // `findUnique` — the ONLY read path (`dunningPolicyFor`). #6374 D3 changed
259
+ // its KEY SHAPE, not its arity: `site_settings` is now unique on
260
+ // (workspace_id, key), so the call is
261
+ // `findUnique({ where: { workspaceId_key: { workspaceId, key } } })` and
262
+ // the `key` string still carries the site alias it always did. The
263
+ // delegate itself is unchanged — a `jest.fn()` does not care about the
264
+ // selector shape — but the comment is, so a reader programming this mock
265
+ // writes the compound selector rather than the removed bare `key`. No
266
+ // route in this repo writes SiteSetting; no write delegate is mocked.
267
+ siteSetting: {
268
+ findUnique: jest.fn(),
269
+ },
185
270
  // ── Inbox namespace (ProjectListing — #4998, reshaped #5033) ─────────────
186
271
  // `create` — the POST route's row write (after routeLead's Lead write).
187
272
  // `findFirst` — the tenant-scoped get/patch reads (scoped by siteId + id, a
@@ -257,6 +342,19 @@ export const prisma = {
257
342
  createMany: jest.fn(),
258
343
  findMany: jest.fn(),
259
344
  },
345
+ // ── Inbox namespace (Appointment — #6085, brief H7) ──────────────────────
346
+ // `updateMany` — the § D7(a) guarded write, one per family (lifecycle /
347
+ // payment / no-show); `findFirst` — the disambiguating read on a zero-match
348
+ // updateMany (row absent vs. present-with-newer-watermark); `create` — the
349
+ // create-eligible arm, wrapped in the caller's own `isUniqueViolation`
350
+ // retry. `findMany` — the tenant-scoped GET /v1/schedule/appointments read
351
+ // (apps/api/src/routes/schedule/appointments.ts).
352
+ appointment: {
353
+ create: jest.fn(),
354
+ findFirst: jest.fn(),
355
+ findMany: jest.fn(),
356
+ updateMany: jest.fn(),
357
+ },
260
358
  // ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
261
359
  usageEvent: {
262
360
  create: jest.fn(),
@@ -290,3 +388,141 @@ export const prisma = {
290
388
  $connect: jest.fn(),
291
389
  $disconnect: jest.fn(),
292
390
  };
391
+
392
+
393
+ // ── #6335 — the explicit workspace fixtures ──────────────────────────────
394
+ //
395
+ // These replace the permissive `workspace.findUnique` default. They take the
396
+ // prisma double they are to program as their FIRST argument, rather than
397
+ // reaching for the `prisma` const above: a test importing this file directly
398
+ // gets its own module instance (Jest keeps manual mocks in a registry of
399
+ // their own, separate from the one a plain `require` of this path fills), so
400
+ // a helper that closed over the local `prisma` would program an object no
401
+ // route ever reads. Passing the double the suite already imports from
402
+ // `../lib/prisma.js` is what makes these work from any suite.
403
+
404
+ /**
405
+ * The structural shape the helpers below need — deliberately NOT
406
+ * `typeof prisma`, so a suite can pass the `prisma` it imported from
407
+ * `lib/prisma.js` (typed as the real client) with no cast at the call site.
408
+ */
409
+ type WorkspaceMockHost = { workspace: { findUnique: unknown } };
410
+
411
+ /**
412
+ * The sentinel Organization id the seeded default workspace hangs off
413
+ * (`database/tenancy.ts`'s reserved-range predicate, ADR-0031 rule 7).
414
+ * `resolveSentinelWorkspaceRow` rejects a default-workspace row whose
415
+ * `organizationId` falls outside that range, so the `"default"` alias path
416
+ * resolves only when the mocked row carries this value.
417
+ */
418
+ export const SENTINEL_ORGANIZATION_ID =
419
+ "org_00000000-0000-7000-8000-000000000001";
420
+
421
+ /** The seeded sentinel workspace id — `DEFAULT_WORKSPACE_ID` in `database/tenancy.ts`. */
422
+ export const SENTINEL_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
423
+
424
+ /**
425
+ * The organization an ORDINARY workspace hangs off, and deliberately NOT a
426
+ * sentinel one: its uuid carries non-zero timestamp bits, so
427
+ * `isSentinel()` (`packages/ids`, `^00000000-0000-7000-8000-…`) is false
428
+ * for it.
429
+ *
430
+ * Stamping the sentinel org on every mocked workspace would be its own
431
+ * DEF-014: `organizationId` is read off this exact row as an AUTHORIZATION
432
+ * input — `tenant-context.ts` denies a stale session when it disagrees with
433
+ * the session's active org, and `isPlatformOperator`
434
+ * (`waitlist-operator-reads.ts`) is `isSentinel(organizationId) && role ===
435
+ * "owner"`, which grants cross-SITE read scope over every site's signup
436
+ * PII. A helper that made every workspace sentinel-org would hand a future
437
+ * ordinary-tenant fixture a platform-operator identity, and would make
438
+ * "two workspaces in different organizations" inexpressible — so the
439
+ * stale-session deny branch could never be driven through it.
440
+ */
441
+ export const ORDINARY_ORGANIZATION_ID =
442
+ "org_01920000-0000-7000-8000-0000000000aa";
443
+
444
+ /** One workspace row a suite says exists: an id, or an id bound to a named org. */
445
+ export type MockWorkspaceRow =
446
+ | string
447
+ | { readonly id: string; readonly organizationId: string };
448
+
449
+ function workspaceFindUnique(client: WorkspaceMockHost): jest.Mock {
450
+ const delegate = client.workspace?.findUnique;
451
+ if (typeof delegate !== "function" || !("mockImplementation" in delegate)) {
452
+ throw new Error(
453
+ "[#6335] prisma.workspace.findUnique is not a jest mock — the suite " +
454
+ "must jest.mock() its prisma module before calling this helper.",
455
+ );
456
+ }
457
+ return delegate as unknown as jest.Mock;
458
+ }
459
+
460
+ /**
461
+ * States, for one test or one suite, EXACTLY which workspace ids exist
462
+ * (#6335). Every other id — a foreign one, a spoofed one, an unset one —
463
+ * resolves `null`, so a route that resolves a tenancy scope fails closed
464
+ * unless the test named the id under test.
465
+ *
466
+ * Keyed on the REQUESTED id, and that is the load-bearing part:
467
+ * `resolveWorkspaceIdForAlias` (`apps/api/src/lib/site.ts`) returns the
468
+ * ALIAS on mere truthiness of the row it gets back and never reads that
469
+ * row's own id, so a blanket `mockResolvedValue({ … })` resolves whatever
470
+ * it is handed — permissive by another name, and the rejection cases each
471
+ * suite now carries could not tell the two apart.
472
+ *
473
+ * Call it from a suite's `beforeEach`, AFTER any `jest.clearAllMocks()`.
474
+ */
475
+ export function mockWorkspaceExists(
476
+ client: WorkspaceMockHost,
477
+ ...rows: readonly MockWorkspaceRow[]
478
+ ): void {
479
+ const existing = new Map<string, string>();
480
+ for (const row of rows) {
481
+ if (typeof row === "string") {
482
+ // The sentinel workspace is the ONE row that hangs off the sentinel
483
+ // organization; everything else gets an ordinary one. See
484
+ // ORDINARY_ORGANIZATION_ID for why this distinction is load-bearing.
485
+ existing.set(
486
+ row,
487
+ row === SENTINEL_WORKSPACE_ID
488
+ ? SENTINEL_ORGANIZATION_ID
489
+ : ORDINARY_ORGANIZATION_ID,
490
+ );
491
+ } else {
492
+ existing.set(row.id, row.organizationId);
493
+ }
494
+ }
495
+ workspaceFindUnique(client).mockImplementation(
496
+ ({ where }: { where: { id: string } }) =>
497
+ Promise.resolve(
498
+ existing.has(where.id)
499
+ ? { id: where.id, organizationId: existing.get(where.id) }
500
+ : null,
501
+ ),
502
+ );
503
+ }
504
+
505
+ /**
506
+ * The `"default"` site alias's resolution path (#6335): `currentSiteId()`
507
+ * returns `"default"`, `resolveWorkspaceIdForAlias` hands that to
508
+ * `resolveSentinelWorkspaceRow`, and only the well-known sentinel workspace
509
+ * — hanging off the sentinel Organization — satisfies it. Any further ids
510
+ * passed resolve beside it.
511
+ */
512
+ export function mockSentinelWorkspaceExists(
513
+ client: WorkspaceMockHost,
514
+ ...alsoExisting: readonly MockWorkspaceRow[]
515
+ ): void {
516
+ mockWorkspaceExists(client, SENTINEL_WORKSPACE_ID, ...alsoExisting);
517
+ }
518
+
519
+ /**
520
+ * The REJECTION path (#6335): no workspace resolves, whatever is asked for.
521
+ * Every suite that depends on an explicit `mockWorkspaceExists` carries at
522
+ * least one test using this, so that explicit setup is proven load-bearing
523
+ * rather than decorative — a route that quietly stopped resolving a tenancy
524
+ * scope would otherwise pass both ways.
525
+ */
526
+ export function mockNoWorkspace(client: WorkspaceMockHost): void {
527
+ workspaceFindUnique(client).mockResolvedValue(null);
528
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * @file auth-email-budget.test.ts — the per-recipient hourly send budget (#5962 § D8c).
3
+ *
4
+ * Written before the implementation. The five properties that matter:
5
+ * - the bucket counts per (recipient, kind) and caps at three per hour;
6
+ * - the window rolls, so an hour later the recipient is clear again;
7
+ * - the store is bounded (MAX_BUCKETS eviction), because the key is a
8
+ * caller-supplied address and an attacker can mint unlimited distinct ones;
9
+ * - a recipient is keyed by a HASH, never by the plaintext address — this
10
+ * module is memory an operator can dump;
11
+ * - the site threshold is an operator SIGNAL, never a refusal. A site-wide
12
+ * cap an attacker can hold open is an indefinitely-holdable lockout
13
+ * (§ D8c, Q6), so nothing here may deny a send because of it.
14
+ *
15
+ * Time is injected rather than faked: the module reads `getClock().now()` by
16
+ * default and accepts an explicit `nowMs`, which is the seam this suite uses.
17
+ */
18
+
19
+ import {
20
+ consumeRecipientBudget,
21
+ siteVolumeAlert,
22
+ RECIPIENT_HOURLY_MAX,
23
+ SITE_HOURLY_ALERT_THRESHOLD,
24
+ __resetAuthEmailBudget,
25
+ __authEmailBucketCount,
26
+ __authEmailBudgetKeys,
27
+ } from "../auth-email-budget.js";
28
+
29
+ const T0 = 1_700_000_000_000;
30
+ const ONE_HOUR_MS = 60 * 60 * 1000;
31
+
32
+ beforeEach(() => {
33
+ __resetAuthEmailBudget();
34
+ });
35
+
36
+ describe("consumeRecipientBudget()", () => {
37
+ it("allows the first RECIPIENT_HOURLY_MAX sends and refuses the next", () => {
38
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
39
+ expect(
40
+ consumeRecipientBudget("user@example.com", "password-reset", {
41
+ nowMs: T0,
42
+ }),
43
+ ).toBe(true);
44
+ }
45
+ expect(
46
+ consumeRecipientBudget("user@example.com", "password-reset", {
47
+ nowMs: T0,
48
+ }),
49
+ ).toBe(false);
50
+ });
51
+
52
+ it("counts per (recipient, kind) — a reset flood cannot exhaust the notice", () => {
53
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
54
+ consumeRecipientBudget("user@example.com", "password-reset", {
55
+ nowMs: T0,
56
+ });
57
+ }
58
+ expect(
59
+ consumeRecipientBudget("user@example.com", "password-changed", {
60
+ nowMs: T0,
61
+ }),
62
+ ).toBe(true);
63
+ });
64
+
65
+ it("is case- and whitespace-insensitive on the recipient", () => {
66
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
67
+ consumeRecipientBudget("User@Example.com", "password-reset", {
68
+ nowMs: T0,
69
+ });
70
+ }
71
+ expect(
72
+ consumeRecipientBudget(" user@example.com ", "password-reset", {
73
+ nowMs: T0,
74
+ }),
75
+ ).toBe(false);
76
+ });
77
+
78
+ it("clears itself when the hour rolls", () => {
79
+ for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
80
+ consumeRecipientBudget("user@example.com", "password-reset", {
81
+ nowMs: T0,
82
+ });
83
+ }
84
+ expect(
85
+ consumeRecipientBudget("user@example.com", "password-reset", {
86
+ nowMs: T0,
87
+ }),
88
+ ).toBe(false);
89
+ expect(
90
+ consumeRecipientBudget("user@example.com", "password-reset", {
91
+ nowMs: T0 + ONE_HOUR_MS + 1,
92
+ }),
93
+ ).toBe(true);
94
+ });
95
+
96
+ it("bounds the store — a fresh identity at capacity evicts the oldest", () => {
97
+ const cap = 50;
98
+ for (let i = 0; i < cap + 10; i++) {
99
+ consumeRecipientBudget(`user${i}@example.com`, "password-reset", {
100
+ nowMs: T0,
101
+ maxBuckets: cap,
102
+ });
103
+ }
104
+ expect(__authEmailBucketCount()).toBeLessThanOrEqual(cap);
105
+ });
106
+
107
+ it("never stores the plaintext recipient", () => {
108
+ consumeRecipientBudget("secret-person@example.com", "password-reset", {
109
+ nowMs: T0,
110
+ });
111
+ expect(__authEmailBudgetKeys().join("|")).not.toContain("secret-person");
112
+ expect(__authEmailBudgetKeys().join("|")).not.toContain("@example.com");
113
+ });
114
+ });
115
+
116
+ describe("siteVolumeAlert()", () => {
117
+ it("does not fire below the threshold", () => {
118
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD - 1; i++) {
119
+ expect(siteVolumeAlert({ nowMs: T0 })).toBe(false);
120
+ }
121
+ });
122
+
123
+ it("fires ONCE per hour past the threshold, and never refuses a send", () => {
124
+ let fired = 0;
125
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 25; i++) {
126
+ if (siteVolumeAlert({ nowMs: T0 })) fired++;
127
+ }
128
+ expect(fired).toBe(1);
129
+
130
+ // The signal is not a gate: a recipient under their own budget still sends.
131
+ expect(
132
+ consumeRecipientBudget("fresh@example.com", "password-reset", {
133
+ nowMs: T0,
134
+ }),
135
+ ).toBe(true);
136
+
137
+ let firedNextHour = 0;
138
+ for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 1; i++) {
139
+ if (siteVolumeAlert({ nowMs: T0 + ONE_HOUR_MS + 1 })) firedNextHour++;
140
+ }
141
+ expect(firedNextHour).toBe(1);
142
+ });
143
+ });
@@ -5,11 +5,15 @@
5
5
  * (a) CONTENT_ADAPTER unset → MDX adapter (existing behavior preserved)
6
6
  * (b) CONTENT_ADAPTER=projection + valid manifest → projection adapter, read parity vs MDX
7
7
  * (c) CONTENT_ADAPTER=projection + missing manifest → clear actionable error at construction
8
+ * (d) CONTENT_ADAPTER=database with no workspaceId → the resolver's named error
9
+ * (e) CONTENT_ADAPTER=database with a workspaceId → an adapter, not a throw (#5852)
8
10
  */
9
11
  import { join, resolve } from "node:path";
10
12
  import { mkdirSync, writeFileSync, rmSync } from "node:fs";
11
13
  import { tmpdir } from "node:os";
12
14
 
15
+ import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
16
+
13
17
  import {
14
18
  getContentAdapter,
15
19
  getContentRoot,
@@ -151,6 +155,25 @@ describe("getContentAdapter() factory", () => {
151
155
  expect(() => getContentAdapter()).toThrow("pnpm content:compile");
152
156
  });
153
157
  });
158
+
159
+ describe("(d) CONTENT_ADAPTER=database → the resolver's workspaceId error", () => {
160
+ test("throws naming workspaceId, because apps/api does not resolve a tenant yet (#5323)", () => {
161
+ process.env.CONTENT_ADAPTER = "database";
162
+
163
+ expect(() => getContentAdapter()).toThrow(/workspaceId/);
164
+ expect(() => getContentAdapter()).toThrow(/CONTENT_ADAPTER=database/);
165
+ });
166
+ });
167
+
168
+ describe("(e) CONTENT_ADAPTER=database + a workspaceId → an adapter (#5852)", () => {
169
+ test("builds the database adapter instead of throwing, once the caller resolves a tenant", () => {
170
+ process.env.CONTENT_ADAPTER = "database";
171
+
172
+ const adapter = getContentAdapter({ workspaceId: DEFAULT_WORKSPACE_ID });
173
+ expect(adapter).toBeDefined();
174
+ expect(typeof adapter.getPageBySlug).toBe("function");
175
+ });
176
+ });
154
177
  });
155
178
 
156
179
  describe("getContentRoot()", () => {
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @file deliverable-asset-store.test.ts
3
+ * @description Unit tests for the deliverable-asset-store boundary (#6310
4
+ * D16) — the two scoped `Asset` reads a magnet deliverable resolution and
5
+ * its serve path need, modelled on `lib/asset-store.ts`'s own test shape.
6
+ */
7
+
8
+ // eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
9
+ jest.mock("../prisma.js");
10
+
11
+ import { prisma } from "../prisma.js";
12
+ import {
13
+ findDeliverableAssetIdByKey,
14
+ readDeliverableAsset,
15
+ } from "../deliverable-asset-store";
16
+
17
+ const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
18
+ const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000002";
19
+ const ASSET_ID = "asset_00000000-0000-7000-8000-0000000000a1";
20
+
21
+ describe("findDeliverableAssetIdByKey", () => {
22
+ beforeEach(() => {
23
+ jest.clearAllMocks();
24
+ });
25
+
26
+ it("queries with exactly { workspaceId, key }", async () => {
27
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID });
28
+
29
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "q3-report.pdf");
30
+
31
+ expect(prisma.asset.findFirst).toHaveBeenCalledWith({
32
+ where: { workspaceId: WORKSPACE_ID, key: "q3-report.pdf" },
33
+ select: { id: true },
34
+ });
35
+ expect(result).toBe(ASSET_ID);
36
+ });
37
+
38
+ it("does not return a same-key row scoped to a different workspace", async () => {
39
+ // The mock proves the CALL is scoped correctly; a real DB enforces the
40
+ // rest. This asserts the where-clause never widens to a foreign
41
+ // workspace even when one exists.
42
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
43
+
44
+ const result = await findDeliverableAssetIdByKey(
45
+ OTHER_WORKSPACE_ID,
46
+ "q3-report.pdf",
47
+ );
48
+
49
+ expect(prisma.asset.findFirst).toHaveBeenCalledWith({
50
+ where: { workspaceId: OTHER_WORKSPACE_ID, key: "q3-report.pdf" },
51
+ select: { id: true },
52
+ });
53
+ expect(result).toBeNull();
54
+ });
55
+
56
+ it("returns null on a miss", async () => {
57
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
58
+
59
+ const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "no-such-key");
60
+
61
+ expect(result).toBeNull();
62
+ });
63
+ });
64
+
65
+ describe("readDeliverableAsset", () => {
66
+ beforeEach(() => {
67
+ jest.clearAllMocks();
68
+ });
69
+
70
+ it("queries with exactly { id, workspaceId } and selects filename/contentType/bytes", async () => {
71
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
72
+ filename: "q3-report.pdf",
73
+ contentType: "application/pdf",
74
+ bytes: Buffer.from("PDF-BYTES"),
75
+ });
76
+
77
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
78
+
79
+ expect(prisma.asset.findFirst).toHaveBeenCalledWith({
80
+ where: { id: ASSET_ID, workspaceId: WORKSPACE_ID },
81
+ select: { filename: true, contentType: true, bytes: true },
82
+ });
83
+ expect(result).toEqual({
84
+ filename: "q3-report.pdf",
85
+ contentType: "application/pdf",
86
+ bytes: expect.any(Buffer),
87
+ });
88
+ });
89
+
90
+ it("returns null for a foreign-workspace id (the scoped read excludes it)", async () => {
91
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
92
+
93
+ const result = await readDeliverableAsset(ASSET_ID, OTHER_WORKSPACE_ID);
94
+
95
+ expect(result).toBeNull();
96
+ });
97
+
98
+ it("wraps the returned bytes as a Buffer view, not a copy", async () => {
99
+ const raw = new Uint8Array([1, 2, 3, 4]);
100
+ (prisma.asset.findFirst as jest.Mock).mockResolvedValue({
101
+ filename: "f.pdf",
102
+ contentType: "application/pdf",
103
+ bytes: raw,
104
+ });
105
+
106
+ const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
107
+
108
+ expect(Buffer.isBuffer(result?.bytes)).toBe(true);
109
+ expect(result?.bytes.byteOffset).toBe(raw.byteOffset);
110
+ expect(result?.bytes.buffer).toBe(raw.buffer);
111
+ });
112
+ });
113
+
114
+ describe("module boundary contract (§1.14 / D16)", () => {
115
+ it("exports no write, no findMany and no unscoped read", () => {
116
+ // The boundary's entire contract is "two named functions, nothing else
117
+ // reads prisma.asset" — asserted at the module surface so a future edit
118
+ // adding a third export or a raw prisma re-export fails this test.
119
+ // eslint-disable-next-line @typescript-eslint/no-var-requires -- asserting the module's own export surface
120
+ const mod = require("../deliverable-asset-store");
121
+ expect(Object.keys(mod).sort()).toEqual(
122
+ ["findDeliverableAssetIdByKey", "readDeliverableAsset"].sort(),
123
+ );
124
+ });
125
+ });
@@ -97,4 +97,24 @@ describe("error-serializer", () => {
97
97
  expect(body["message"]).toBeUndefined();
98
98
  expect(body["statusCode"]).toBeUndefined();
99
99
  });
100
+
101
+ // #6019 AC-2 — `pathWithoutQuery` (#5970 security review, finding 1): a
102
+ // route may carry its credential in the query string
103
+ // (`/v1/requests/:id/confirm?token=…` does), and this handler logs `path`
104
+ // on EVERY 5xx. A future refactor restoring the raw `request.url` must turn
105
+ // this red.
106
+ it("scrubs a query-string credential from the logged path on a 5xx", async () => {
107
+ await server.inject({
108
+ method: "GET",
109
+ url: "/boom?token=SUPERSECRETTOKEN",
110
+ });
111
+
112
+ const logged = logLines.find(
113
+ (line) => line["msg"] === "internal server error",
114
+ );
115
+ expect(logged?.["path"]).toBe("/boom");
116
+ const text = JSON.stringify(logLines);
117
+ expect(text).not.toContain("SUPERSECRETTOKEN");
118
+ expect(text).not.toContain("token=");
119
+ });
100
120
  });