create-ailk 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (299) hide show
  1. package/README.md +55 -8
  2. package/component-catalog.json +336 -27
  3. package/component-catalog.md +25 -12
  4. package/dist/lib/api-boot-assertion.d.ts +46 -0
  5. package/dist/lib/api-boot-assertion.js +70 -0
  6. package/dist/lib/import-graph.d.ts +29 -0
  7. package/dist/lib/import-graph.js +140 -0
  8. package/dist/module-architecture.d.ts +1 -0
  9. package/dist/module-architecture.js +253 -127
  10. package/dist/surfaces.js +105 -7
  11. package/dist/template-manifest.d.ts +33 -0
  12. package/dist/template-manifest.js +99 -16
  13. package/dist/template-names.d.ts +1 -1
  14. package/dist/template-names.js +1 -0
  15. package/package.json +3 -2
  16. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
  17. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
  18. package/site-templates/marketing-site/ailk-template.json +1 -1
  19. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
  20. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
  21. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
  22. package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
  23. package/site-templates/minimalism-site/ailk-template.json +2 -1
  24. package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
  25. package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
  26. package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
  27. package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
  28. package/site-templates/simple-service/ailk-template.json +10 -0
  29. package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
  30. package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
  31. package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
  32. package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
  33. package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
  34. package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
  35. package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
  36. package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
  37. package/site-templates/simple-service/provenance.json +4 -0
  38. package/templates/.env.example +72 -7
  39. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
  40. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
  41. package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
  42. package/templates/apps/api/.env.example +13 -0
  43. package/templates/apps/api/CLAUDE.md +26 -2
  44. package/templates/apps/api/jest.config.cjs +3 -0
  45. package/templates/apps/api/package.json +1 -0
  46. package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
  47. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
  48. package/templates/apps/api/src/__tests__/server.test.ts +471 -0
  49. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
  50. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
  51. package/templates/apps/api/src/config/index.ts +62 -0
  52. package/templates/apps/api/src/config/modules.ts +7 -3
  53. package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
  54. package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
  55. package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
  56. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
  57. package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
  58. package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
  59. package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
  60. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
  61. package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
  62. package/templates/apps/api/src/lib/content-adapter.ts +57 -31
  63. package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
  64. package/templates/apps/api/src/lib/error-serializer.ts +23 -3
  65. package/templates/apps/api/src/lib/outbound-http.ts +7 -2
  66. package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
  67. package/templates/apps/api/src/lib/site.ts +208 -9
  68. package/templates/apps/api/src/lib/stripe.ts +66 -0
  69. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
  70. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
  71. package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
  72. package/templates/apps/api/src/openapi/spec.ts +543 -2
  73. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
  74. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
  75. package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
  76. package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
  77. package/templates/apps/api/src/routes/auth/user.ts +61 -0
  78. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
  80. package/templates/apps/api/src/routes/checkout/index.ts +34 -0
  81. package/templates/apps/api/src/routes/checkout/session.ts +139 -0
  82. package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
  83. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
  84. package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
  85. package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
  86. package/templates/apps/api/src/routes/content/get-page.ts +94 -12
  87. package/templates/apps/api/src/routes/content/index.ts +13 -73
  88. package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
  89. package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
  90. package/templates/apps/api/src/routes/content/search.ts +129 -0
  91. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
  92. package/templates/apps/api/src/routes/leads/index.ts +100 -13
  93. package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
  94. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
  95. package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
  96. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
  97. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
  98. package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
  99. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
  100. package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
  101. package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
  102. package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
  103. package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
  104. package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
  105. package/templates/apps/api/src/routes/requests/index.ts +54 -0
  106. package/templates/apps/api/src/routes/webhooks/README.md +265 -0
  107. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
  108. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
  109. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
  110. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
  111. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
  112. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
  113. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
  114. package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
  115. package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
  116. package/templates/apps/api/src/server.ts +439 -49
  117. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
  118. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
  119. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
  120. package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
  121. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
  122. package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
  123. package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
  124. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
  125. package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
  126. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
  127. package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
  128. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
  129. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
  130. package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
  131. package/templates/apps/api/src/services/abandon-email.ts +8 -2
  132. package/templates/apps/api/src/services/aeo-score.ts +49 -12
  133. package/templates/apps/api/src/services/audit-log.ts +668 -0
  134. package/templates/apps/api/src/services/consent.ts +18 -0
  135. package/templates/apps/api/src/services/default-tenant.ts +70 -31
  136. package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
  137. package/templates/apps/api/src/services/dunning-policy.ts +294 -0
  138. package/templates/apps/api/src/services/entitlements.ts +80 -0
  139. package/templates/apps/api/src/services/lead-promotion.ts +13 -14
  140. package/templates/apps/api/src/services/lead-routing.ts +161 -2
  141. package/templates/apps/api/src/services/leads-inbox.ts +23 -2
  142. package/templates/apps/api/src/services/listing-config.ts +27 -9
  143. package/templates/apps/api/src/services/project-listings.ts +201 -6
  144. package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
  145. package/templates/apps/api/src/services/request-confirm.ts +646 -0
  146. package/templates/apps/api/src/services/revalidation.ts +173 -0
  147. package/templates/apps/api/src/services/verified-email.ts +71 -0
  148. package/templates/apps/api/src/vercel-handler.ts +75 -35
  149. package/templates/apps/mcp/.env.example +17 -0
  150. package/templates/apps/mcp/CLAUDE.md +57 -24
  151. package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
  152. package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
  153. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
  154. package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
  155. package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
  156. package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
  157. package/templates/apps/mcp/__tests__/index.test.ts +39 -0
  158. package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
  159. package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
  160. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
  161. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
  162. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
  163. package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
  164. package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
  165. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
  166. package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
  167. package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
  168. package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
  169. package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
  170. package/templates/apps/mcp/__tests__/server.test.ts +67 -14
  171. package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
  172. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
  173. package/templates/apps/mcp/jest.config.cjs +13 -0
  174. package/templates/apps/mcp/package.json +2 -0
  175. package/templates/apps/mcp/src/abuse/index.ts +159 -24
  176. package/templates/apps/mcp/src/config/modules.ts +10 -5
  177. package/templates/apps/mcp/src/http-transport.ts +102 -29
  178. package/templates/apps/mcp/src/index.ts +73 -11
  179. package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
  180. package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
  181. package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
  182. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
  183. package/templates/apps/mcp/src/lib/prisma.ts +39 -0
  184. package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
  185. package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
  186. package/templates/apps/mcp/src/server.ts +59 -35
  187. package/templates/apps/mcp/src/tools/audit.ts +26 -2
  188. package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
  189. package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
  190. package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
  191. package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
  192. package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
  193. package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
  194. package/templates/apps/mcp/src/tools/index.ts +6 -4
  195. package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
  196. package/templates/apps/mcp/src/tools/purchase.ts +128 -45
  197. package/templates/apps/mcp/src/tools/read_content.ts +20 -6
  198. package/templates/apps/mcp/src/tools/search_content.ts +11 -4
  199. package/templates/apps/web/.env.example +71 -5
  200. package/templates/apps/web/CLAUDE.md +44 -0
  201. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
  202. package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
  203. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
  204. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
  205. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
  206. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
  207. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
  208. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
  209. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
  210. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
  211. package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
  212. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
  213. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
  214. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
  215. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
  216. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
  217. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
  218. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
  219. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
  220. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
  221. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
  222. package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
  223. package/templates/apps/web/app/[locale]/layout.tsx +133 -87
  224. package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
  225. package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
  226. package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
  227. package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
  228. package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
  229. package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
  230. package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
  231. package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
  232. package/templates/apps/web/app/api/revalidate/route.ts +194 -0
  233. package/templates/apps/web/auth.ts +407 -8
  234. package/templates/apps/web/jest.config.cjs +8 -4
  235. package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
  236. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
  237. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
  238. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
  239. package/templates/apps/web/lib/auth-email.ts +318 -0
  240. package/templates/apps/web/lib/auth-env.ts +85 -0
  241. package/templates/apps/web/lib/auth-routes.ts +55 -0
  242. package/templates/apps/web/lib/meeting-slugs.ts +53 -0
  243. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  244. package/templates/apps/web/messages/en.json +187 -0
  245. package/templates/apps/web/package.json +2 -1
  246. package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
  247. package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
  248. package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
  249. package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
  250. package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
  251. package/templates/database/CHANGELOG.md +142 -0
  252. package/templates/database/README.md +10 -3
  253. package/templates/database/__tests__/appointment.db.test.ts +106 -0
  254. package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
  255. package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
  256. package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
  257. package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
  258. package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
  259. package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
  260. package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
  261. package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
  262. package/templates/database/__tests__/tenancy.test.ts +141 -0
  263. package/templates/database/config/schema.prisma +79 -2
  264. package/templates/database/content/schema.prisma +87 -11
  265. package/templates/database/inbox/schema.prisma +452 -9
  266. package/templates/database/jest.config.cjs +5 -3
  267. package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
  268. package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
  269. package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
  270. package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
  271. package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
  272. package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
  273. package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
  274. package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
  275. package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
  276. package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
  277. package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
  278. package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
  279. package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
  280. package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
  281. package/templates/database/package.json +1 -1
  282. package/templates/database/tenancy.ts +86 -1
  283. package/templates/database/tsconfig.json +1 -0
  284. package/versions.json +34 -34
  285. package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
  286. package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
  287. package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
  288. package/templates/apps/api/src/routes/content/create.ts +0 -115
  289. package/templates/apps/api/src/routes/content/delete.ts +0 -82
  290. package/templates/apps/api/src/routes/content/update.ts +0 -112
  291. package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
  292. package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
  293. package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
  294. package/templates/apps/mcp/src/tools/create_page.ts +0 -96
  295. package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
  296. package/templates/apps/mcp/src/tools/update_page.ts +0 -92
  297. package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
  298. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
  299. /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
@@ -1,5 +1,147 @@
1
1
  # @working-theory/database
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#5813](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5813) [`518c590`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/518c59023876a0212504ace9a683084df0168be6) Thanks [@claudio-planck](https://github.com/claudio-planck)! - Generalize `Artifact` into the workspace's content store, and add `Asset` ([#5800](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5800), roadmap D1/D7).
8
+
9
+ `artifacts.session_id` was `NOT NULL` behind an `ON DELETE CASCADE`, so deleting an agent session deleted every artifact it had produced. It is now nullable behind `ON DELETE SET NULL`: the session is provenance, the workspace owns the artifact. Beside it, four columns the content store needs — `content_type` (the discriminator; `text/page` is what the editor writes), `locale` (defaulting to ADR 0019's `product.default_locale`), `slug` (NULL until publish) and `published_version_id`. `(workspace_id, content_type, locale, slug)` is unique and `(workspace_id, content_type, updated_at)` is the list index. `published_version_id` is a plain pointer, never a relation and never `is_current`, which remains the draft tip and is never the publish mechanism.
10
+
11
+ `content_type` is ADDITIVE beside the existing `type` column rather than a rename: `apps/api/src/routes/workspaces/sessions.ts` reads `type` and keeps working unchanged. The migration backfills `content_type` from `type`, so the two agree row-for-row; new code reads `content_type`.
12
+
13
+ `Asset { workspaceId, key, filename, contentType, bytes, size }` follows the inbox namespace's `DeliverableAsset` precedent — bytes in the row, no new infrastructure — with `(workspace_id, key)` unique and a relation-free `workspaceId` (ADR 0009 invariant 1). `@working-theory/ids` registers its `asset_` prefix (ADR 0031 rule 3) and exports `zAssetId`.
14
+
15
+ The migration brackets the FK relaxation with zero-row assertions — no row already NULL and no orphan before the swap; no row lost, no row nulled and no `content_type`/`type` drift after it — so it applies to an empty database and to a populated one, and aborts rather than backfilling past a surprise.
16
+
17
+ - [#6139](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6139) [`9f45297`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/9f4529701aaf2f56acbd2be416e8a8c17358e1aa) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Appointments from Cal.com webhooks ([#6085](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6085), brief H7).
18
+
19
+ Adds `POST /v1/webhooks/calcom` — a signed receiver (HMAC-SHA256 over the raw
20
+ body, `x-cal-signature-256`) that normalizes six `BOOKING_*` trigger events
21
+ into a new `Appointment` row, guarded by three independent per-family
22
+ watermarks (lifecycle / payment / no-show) and a 15-minute replay window —
23
+ and `GET /v1/schedule/appointments`, a tenant-guarded, `owner|admin`-floored
24
+ read of the site's own appointments, scoped strictly to
25
+ `tenant.workspaceId` (never `currentSiteId()`, never a platform-operator
26
+ escape hatch).
27
+
28
+ `@working-theory/database` gains the `Appointment` model
29
+ (`database/inbox/schema.prisma`) and its migration.
30
+ `@working-theory/ids` registers the `appointment` → `appt` prefix (ADR-0031
31
+ rule 3). `@working-theory/validation` gains `AppointmentSchema`,
32
+ `AppointmentListSchema`, `listAppointmentsInputSchema` /
33
+ `listAppointmentsOutputSchema`, and the inbound Cal.com webhook envelope
34
+ schemas. `@working-theory/api-client` gains `schedule.appointments()`.
35
+ `create-ailk` adds the receiver + read + service files (and their tests) to
36
+ the `scheduling` module's `ownedFiles` — a keyless scaffold is unaffected;
37
+ a `--modules scheduling` scaffold now carries the new surface.
38
+
39
+ - [#6118](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6118) [`8b20866`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/8b20866470f5aa3ef9fbbba1d11e4fc3b998da86) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `Asset` gains three columns the lightbox edits in place ([#6092](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6092), S4/D10): `title String?`, `description String?`, `tags String[] @default([])`.
40
+
41
+ `title` and `description` are nullable with no default — `NULL` means the owner has not written one, distinct from an empty string meaning they cleared it, and neither is backfilled from `filename`. `tags` is non-null with an empty-array default: an untagged asset is an empty set, not an unknown one. The column stores tag SLUGS (`ad-creative`, `workspace-memory`, …), never the human labels a surface renders them as, and is deliberately unconstrained — the asset vocabulary and the document vocabulary (a document is a row in this same store) ride the one column, and a fixed set is a UI affordance, not a database constraint.
42
+
43
+ Delivered by one additive migration, `20260918120000_asset_metadata_columns` — no column dropped or renamed, no value backfilled, the `(workspace_id, key)` identity and the `asset_` UUIDv7 CHECK untouched. An `INSERT` naming none of the three columns still succeeds.
44
+
45
+ - [#6202](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6202) [`b85e147`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/b85e147d0d26150eb1a00e1933df138087727628) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `FlowDefinition` and `FlowSession` gain a nullable `workspaceId` column, backfilled 1:1 from `siteId` ([#6155](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6155)).
46
+
47
+ The literal alias `"default"` resolves to the seeded sentinel workspace; every other alias resolves only by exact `workspaces.id` equality. A row whose `siteId` matches no workspace is left `NULL` — unset, never guessed — and stays invisible to every tenant-scoped read rather than visible to the wrong one. `siteId` is unchanged and the public flow engine keeps resolving it; nothing about the request or response shape of `/v1/flows/*` or either MCP flow tool changes.
48
+
49
+ `database/tenancy.ts` also gains `resolveSentinelWorkspaceRow(prisma)` — the sentinel-existence sibling of `resolveDefaultWorkspaceRow`, carrying no `workspace.count()` gate, so a correctly-configured multi-workspace deployment that has not pinned `AILK_SITE_ID` still resolves the `"default"` alias.
50
+
51
+ - [#6218](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6218) [`80f498e`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/80f498e3dcd3d70931045dc376c293c0c9a88c65) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `FlowDefinition` gains four operator-facing columns — `kind`, `title`, `landingPage`, `status` ([#6156](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6156)) — backfilled for every existing row by a structural rule (never from slug), with the visitor-facing `config` left byte-identical.
52
+
53
+ `status` (`draft` | `live`) carries NO column default and is backfilled `'live'` — every row that exists today is already serving, and a silent default would make publication the thing that happens when a future writer says nothing. `title` is likewise `NOT NULL` with no default, backfilled from `slug`. `kind` (`special-report` | `diagnostic` | `listicle` | `waitlist` | `contact` | `newsletter`) keeps its column default (`'contact'`) and is reclassified only where the row's own structure (a projected listing, a recommending-mode config, a `deliverables` block) says otherwise. `landingPage` is nullable with no backfill — a magnet has three public addresses, and NULL means the canonical route serves it.
54
+
55
+ `@working-theory/validation`'s `flow.ts` gains `flowKindSchema` / `flowStatusSchema` / `flowLandingPageSchema` as standalone exports — never members of `flowConfigShapeSchema`, which stays `.strict()` and gains none of the four keys, so every stored `configSnapshot` keeps parsing unchanged.
56
+
57
+ A `status` of `draft` refuses a NEW walk from starting (`resolveSession`'s definition branch) while a walk already in progress finishes on its pinned snapshot — the `sessionId` branch never re-reads the definition. `upsertFlowDefinition` supplies `kind`/`title`/`status` on CREATE only, never on UPDATE, so a re-seed or re-materialize can never overwrite what an operator later sets. The public routes and both MCP flow tools are unchanged.
58
+
59
+ - [#6274](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6274) [`3bd8785`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/3bd87854c6589556426894720c85f54bf2daa801) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `ProjectListing` and `Lead` gain a nullable `workspaceId` column (`workspace_id`), indexed for a per-workspace list and backfilled from `siteId` for any existing row whose alias resolves to a real workspace ([#6203](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6203)). `tenantDb(tenant)` gains `projectListings` and `leads` read accessors, scoped by the injected `workspaceId` and never returning a row from another workspace. `siteId` stays the legacy alias, unchanged, on both models.
60
+
61
+ - [#6280](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6280) [`2e7b0d2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2e7b0d2411944002104d5105edb6e8d59c8434c5) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `Lead` gains a nullable triage stamp — `handledAt` (`handled_at`) and `handledBy` (`handled_by`), the actor — plus a `(workspaceId, handledAt)` index for the Needs-you read ([#6204](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6204)). Existing rows are unaffected by the purely-additive migration and read as unhandled. `@working-theory/validation` gains `MESSAGE_LEAD_SOURCES` / `MESSAGE_LEAD_SOURCE_LIST` and `isMessageSource()` — the one closed allowlist declaring which `Lead.source` values are messages (a lead that needs an answer, operator decision Q1(a)), never a denylist and never a pattern test. `tenantDb(tenant).leads` gains `listUnhandledMessages` (bounded, workspace-scoped) and `markHandled` (an idempotent compare-and-swap), and `leads-inbox.ts` exposes the role-floored service pair over them. No route and no MCP tool — nothing new is reachable from this unit.
62
+
63
+ - [#6360](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6360) [`2809d30`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2809d307d4d130541cb29ea7176b4af17e58679c) Thanks [@claudio-planck](https://github.com/claudio-planck)! - `Lead`, `ProjectListing`, `FlowDefinition`, `FlowSession` gain NOT NULL `workspaceId` plus a `CHECK (workspace_id = site_id)` equality constraint, and `WorkspaceSiteIdentity` gains `@@unique([id, workspaceId])` ([#6254](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6254), the tenancy-fence keystone).
64
+
65
+ The issue's literal ask — a composite foreign key from the four fenced models to `WorkspaceSiteIdentity` — is not buildable against this tree: it crosses the `inbox` → `config` namespace boundary ADR-0009 forbids, `siteId` never holds a `WorkspaceSiteIdentity.id` (it holds the workspace id), and `WorkspaceSiteIdentity` is an optional per-workspace config blob whose required existence would break anonymous lead capture, a never-gate surface. The equality CHECK delivers the same guarantee — Postgres rejects a row whose `workspaceId` disagrees with its site — without a relation and without crossing the boundary; it is strictly tighter than the FK would have been. `docs/database/namespaces.md` documents the pattern for the fan-out ([#6255](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6255)–[#6260](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6260), [#6171](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6171), [#6314](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6314)), which land their own model changes into this same migration, `database/migrations/20260922120000_tenancy_fence`.
66
+
67
+ `DeliverableGrant` (the fifth already-keyed model) is deliberately NOT touched — its `workspaceId` has no source column to derive from and `NULL` is the serve path's fail-closed sentinel; [#6257](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6257) fences it separately, at mint grain.
68
+
69
+ Every write site that creates a `Lead`, `ProjectListing`, `FlowDefinition` or `FlowSession` row now derives `workspaceId` server-side from the resolved site alias (`apps/api/src/lib/site.ts`'s `resolveSiteWorkspaceId()` / `resolveWorkspaceIdForAlias()`), never from a request body. `routeLead`'s file-fallback no longer swallows a constraint violation into a silent success — it re-raises as a `persist_failed` error, while a genuine infrastructure outage still takes the file fallback unchanged.
70
+
71
+ - [#6418](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6418) [`14dcb0f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/14dcb0fe7864a13f3fb9d573c303ddb8780dbdf5) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `ProjectListing` records the listicle magnet that captured it, so a magnet's Applications list can be scoped to its own submissions ([#6314](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6314)).
72
+
73
+ Nothing constrains a workspace to one listicle-kind `FlowDefinition` — the model carries `@@unique([siteId, slug])` and no `@@unique([siteId, kind])` — so a workspace-only Applications list would silently show two listicle magnets' applications on both of their detail pages, with nothing to notice it. `ProjectListing` carried no reverse link to the magnet that captured it: the two pointers that existed (`FlowDefinition.listingId` and `ProjectListing.experimentKey`) are both written at APPROVAL time, when an approved listing is projected into its own diagnostic sub-flow, and name a different flow entirely.
74
+
75
+ **`@working-theory/database`** — `ProjectListing` gains `capturedByFlowId String? @map("captured_by_flow_id")` and `@@index([workspaceId, capturedByFlowId])`. A plain scalar ref: no `@relation`, no foreign key, no `onDelete`, matching the intra-`inbox` convention every sibling reference on this model already follows (`leadId`, `listingId`, `grantId`, `flowSessionId` carry none, and all 24 foreign keys in the migration history target `auth`). Integrity rests on the server-side resolve and on the read carrying both scope terms; the column's own schema comment states that cost rather than arguing it away. Permanently nullable and **not** a backfill target — no existing row carries evidence naming the magnet that captured it, so every row written before this reads `NULL` for the life of the row: unattributed, absent from every per-magnet list, and unchanged in the workspace Inbox. `NULL` means "not attributed", never "attribution failed" and never "captured directly".
76
+
77
+ **`@working-theory/validation`** — `projectListingStartSchema` gains one optional key, `capturedByFlowSlug: flowSlugSchema.optional()`. Additive on a `.strict()` schema, so every existing caller (the agent channel included) is accepted unchanged. It is a slug rather than an id because no public surface hands a visitor a `flow_<uuidv7>`; the server resolves it against the already-resolved `siteId`/`workspaceId` plus `kind: "listicle"` and stores the id it finds. A slug that resolves to nothing — naming no magnet, another workspace's magnet, or a non-listicle magnet — writes `NULL` and still returns `201`: this is a never-gate anonymous route where a refused body is a lost application, and a distinguishable error would make it a cross-workspace magnet-slug oracle. The operator's signal is a PII-safe server-side log line instead.
78
+
79
+ - [#6383](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6383) [`e71eb5d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/e71eb5d0d5796732e19dfab7768048a1009ef66d) Thanks [@claudio-planck](https://github.com/claudio-planck)! - **breaking** — the tenancy fence fan-out: thirteen models gain a required `workspace_id`.
80
+
81
+ `Subscriber`, `Callback`, `ConsentGrant`, `Appointment`, `DeliverableAsset`,
82
+ `FlowCheckout`, `WaitlistExperiment`, `WaitlistScore`, `WaitlistFollowUpSend`,
83
+ `PlaybookInstance` and `SiteListingConfig` each gain a NOT NULL
84
+ `workspaceId String @map("workspace_id")`, an `@@index([workspaceId])`, a
85
+ `<table>_workspace_id_format` CHECK and a
86
+ `<table>_workspace_site_agree CHECK (workspace_id = site_id)`. Every
87
+ `create`/`upsert` of these models must now supply `workspaceId`, and it must
88
+ equal the row's `site_id` — a server-resolved workspace id, never a value read
89
+ from a request body.
90
+
91
+ Two models carry no `site_id` and take a different shape:
92
+
93
+ - `ListingCsvSend` gains NOT NULL `workspace_id` plus the format CHECK only —
94
+ no equality CHECK and no foreign key. It is backfilled and stamped from the
95
+ parent `ProjectListing`.
96
+ - `SiteSetting` gains NOT NULL `workspace_id`, and **its unique key moves from
97
+ `key` to `(workspaceId, key)`** — `key` alone is no longer unique, so
98
+ `siteSetting.findUnique`/`upsert` must pass the compound
99
+ `workspaceId_key` selector. Existing rows backfill to the sentinel
100
+ workspace, and every `key` string is left verbatim.
101
+
102
+ `DeliverableGrant.workspaceId` stays **nullable** — NULL remains the serve
103
+ path's fail-closed sentinel for a legacy grant — but gains a NULL-tolerant
104
+ `CHECK (workspace_id IS NULL OR workspace_id = site_id)`, and both mint sites
105
+ now stamp it.
106
+
107
+ Per [#6254](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6254) AC-1 this lands as an amendment to the single M32 migration
108
+ `20260922120000_tenancy_fence` rather than a new migration directory. The
109
+ amendment is a no-op on a database that already applied that migration, which
110
+ is safe here under the operator's 2026-09-20 disposition that no persistent
111
+ database carries state that must survive (spec § 1.5); if that ever stops
112
+ holding, the SQL moves verbatim into a new migration directory and nothing
113
+ else changes.
114
+
115
+ - [#5939](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/5939) [`0d2a2b5`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/0d2a2b54eb622a904d4a8b80c31a495651ef19e7) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Add the shared one-workspace resolution predicate to the `./tenancy` subpath ([#5876](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/5876)).
116
+
117
+ `resolveDefaultWorkspaceRow(prisma)` and its structural `WorkspacePredicateClient` type join the sentinel constants already exported there — the deployment-level predicate `apps/api/src/services/default-tenant.ts` and `apps/mcp`'s new tenant resolution both delegate to, so the one-workspace check exists exactly once in the repository (NFR-7). Additive: no existing export changes shape.
118
+
119
+ ### Patch Changes
120
+
121
+ - [#6172](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6172) [`20bab3b`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/20bab3ba318e4ecb8ae7489e4ad638808a3a165d) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Fix `Appointment.noShow` — restore its `@map("no_show")`, matching every sibling field on the model and the column the `20260918120000_add_appointment` migration actually creates ([#6162](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6162)).
122
+
123
+ Without the mapping, Prisma's generated client targeted the literal column `noShow`, which the live database does not have — every `prisma.appointment.create()` (and any other read/write touching this field) failed at runtime with `The column noShow of relation appointments does not exist in the current database.` No schema shape changed and no migration is added; this restores the field's intended, already-migrated column target. A new `database/__tests__/appointment.db.test.ts` proves the round trip against a live migrated database, and `tools/check-prisma-migration-drift.ts` — a new preflight check — fails when a Prisma schema field/model's implied column/table name disagrees with what the migration history actually creates, catching this exact class before merge.
124
+
125
+ - [#6367](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6367) [`d3f599e`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/d3f599e1f1cadd3d998b92514231b0b4b7b7f77c) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `ProjectListing.consentAt` now declares `@db.Timestamptz(3)`, matching the `TIMESTAMPTZ` column its migration (`20260909180000_listing_draft_and_fields`) actually created ([#6171](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6171)). The Prisma schema previously declared this legal-consent timestamp as a plain, timezone-naive `DateTime`, which Prisma defaults to `TIMESTAMP(3)` on Postgres — a drift between the schema's declared type and the live column's real type. No migration is needed; the column already exists as `TIMESTAMPTZ`.
126
+
127
+ - [#6224](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6224) [`454edc7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/454edc7bb960666c57b7f2651f644b3788194698) Thanks [@samuelhenry](https://github.com/samuelhenry)! - Add the enabled-meeting-slugs gate ([#6183](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6183) S9b) — the operator's control over which meeting types the site's OWN PAGES publish.
128
+
129
+ A fourth workspace site-config blob, `MeetingsSchema` (`enabledSlugs`, beside `ThemeSchema`/`SiteIdentitySchema`/`SEOSchema`), reads and writes through a new route, `GET|PUT /v1/workspaces/:workspaceId/meetings` (`apps/api/src/routes/workspaces/meetings.ts`) — a new route file and a new handler, but the SAME path shape, tenant guard and `{ok,data}` envelope as its three siblings, registered in its own scope gated on the `scheduling` module alone (its GET needs the scheduling provider seam, which a `workspace`/`multi-tenant`-only build does not carry). The write floor is `owner|admin`, matching every other publication-affecting write in this route family; the GET carries the SAME floor, since it echoes the deployment's provider catalog joined with which rows are enabled — a plain member should not read that. The GET also lists the scheduling provider's own bookable event types (`listEventTypes`, [#6182](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6182)), joined with `enabled: boolean`, so the Admin Meetings page can render a toggle list.
130
+
131
+ A new anonymous route, `GET /v1/schedule/meeting-types`, is what lets the public `/meet` surface — which holds no session — honour the gate at request time for its OWN two pages: `/meet/[slug]` now 404s a disabled slug (gating both the page and `generateMetadata`, so a disabled event's title/description never reach the document head), and `/meet` lists the enabled set rather than a single configured link, titled from one batched `event-meta` read. An unconfigured deployment falls back to the singleton `SCHEDULING_CAL_LINK` already advertises, so no shipped page goes dark on upgrade. Both pages are `force-dynamic`: enabling or disabling a slug takes effect on the next request, no deploy.
132
+
133
+ **This is a publication gate on this site's own pages, not an authorization boundary on the provider.** The three anonymous scheduling routes — `POST /v1/schedule/book`, `GET /v1/schedule/availability`, `GET /v1/schedule/event-meta` — are unchanged and still accept any `calLink` naming ANY event the provider knows about, disabled or not; none of them consults `enabledSlugs`. A disabled event's own page on this site 404s, but the event is still bookable at the provider's own public page and through those three routes directly, and nothing this repo can change alters that. Gating them would change a fenced scheduling-route contract this unit does not touch, and would not close the provider's own page regardless.
134
+
135
+ - [#6407](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/pull/6407) [`87535c1`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/87535c162654d76af314395eb55e87bd0ef96021) Thanks [@samuelhenry](https://github.com/samuelhenry)! - `database/inbox/schema.prisma` drops the 'PLACEMENT IS A HARD REQUIREMENT' comment blocks ([#6261](https://github.com/working-theory-labs/ai-launch-kit-internal/issues/6261)).
136
+
137
+ Comment-only: no model, field, mapping, index or migration changes, so the generated client is byte-identical. The blocks documented a workaround for the tenant-scope drift guard's old model-body collector, which matched `/model\s+(\w+)\s+\{([^}]*)\}/` and stopped at the first closing-curly CHARACTER — including one written inside a doc comment — and so forced `workspaceId` to be declared above any comment that might contain one.
138
+
139
+ The reader behind that guard now lives in `tools/lib/tenant-scope-schema.ts` and parses model bodies by brace DEPTH, outside `//` and `///` comments and outside double-quoted strings. Field placement no longer carries meaning for it, so the per-field 'DO NOT MOVE this field' clauses that referenced the blocks go with them. No field moved.
140
+
141
+ - Updated dependencies [[`8eadc43`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/8eadc437285a0c0121c9f4750ff83dac9591729e), [`2666bf3`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2666bf3ed24324a37a51cfc5c68019ad5d26403e), [`94ebca8`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/94ebca8f3c1c9f3b744182c720e91417012ecc9b), [`518c590`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/518c59023876a0212504ace9a683084df0168be6), [`8f931f1`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/8f931f1af0d170fb743bf027634154cd53aa1db9), [`ecab283`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ecab283284d5f6a6ba4a2a08b018b077257de288), [`3bb6a93`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/3bb6a93f41c7dfd8d24ea017f5c403edaae03fcf), [`ab6b792`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/ab6b79258a369ff909fa62d7bffca7fe264fe532), [`9e7df0d`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/9e7df0d93343e126c7b058a66b71ad4dda8ae9e4), [`28f14b5`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/28f14b50dfe29d2d9bf8e75aa368eb1ef3767d47), [`9f45297`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/9f4529701aaf2f56acbd2be416e8a8c17358e1aa), [`80f498e`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/80f498e3dcd3d70931045dc376c293c0c9a88c65), [`454edc7`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/454edc7bb960666c57b7f2651f644b3788194698), [`2e7b0d2`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/2e7b0d2411944002104d5105edb6e8d59c8434c5), [`6d061a0`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/6d061a0cd48baec20899cec2962ff6877addaa85), [`14dcb0f`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/14dcb0fe7864a13f3fb9d573c303ddb8780dbdf5), [`f917f0b`](https://github.com/Working-Theory-Labs/ai-launch-kit-internal/commit/f917f0b182ea8daf657b4762551ea5e86fc90616)]:
142
+ - @working-theory/validation@0.25.0
143
+ - @working-theory/ids@0.8.0
144
+
3
145
  ## 0.24.0
4
146
 
5
147
  ### Patch Changes
@@ -143,11 +143,18 @@ await prisma.userData.upsert({
143
143
  create: { userId, key: "preferences", value: { theme: "dark" } },
144
144
  });
145
145
 
146
- // Store site-wide cache
146
+ // Store a per-workspace setting.
147
+ //
148
+ // #6374 D3 — `SiteSetting` is keyed by (workspaceId, key), NOT by `key`
149
+ // alone: `key` lost its global @unique so two workspaces may each hold a
150
+ // `cached_config` row. `workspaceId` is resolved SERVER-SIDE (see
151
+ // apps/api/src/lib/site.ts's `resolveWorkspaceIdForAlias`) and never read
152
+ // from a request body — `site_settings.workspace_id` is NOT NULL with a
153
+ // format CHECK, so a raw site alias is refused by Postgres.
147
154
  await prisma.siteSetting.upsert({
148
- where: { key: "cached_config" },
155
+ where: { workspaceId_key: { workspaceId, key: "cached_config" } },
149
156
  update: { value: platformConfig },
150
- create: { key: "cached_config", value: platformConfig },
157
+ create: { workspaceId, key: "cached_config", value: platformConfig },
151
158
  });
152
159
  ```
153
160
 
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file appointment.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof that `Appointment.noShow` correctly targets the
5
+ * `no_show` column (#6162). Requires DATABASE_URL pointing at a Postgres with
6
+ * the migrations applied (`prisma migrate deploy`) — the whole suite SKIPS
7
+ * when DATABASE_URL is unset, keeping the workspace Jest run DB-free (the
8
+ * repo convention — see `prefixed-ids.db.test.ts`; the `apps/api Route Tests
9
+ * (Postgres)` workflow runs this file with a real DB, per its own explicit
10
+ * `pnpm --filter @working-theory/database test` step's precedent).
11
+ *
12
+ * The bug this guards: `Appointment.noShow` carried no `@map("no_show")` —
13
+ * every sibling field on the model does — while the migration that creates
14
+ * the table (`20260918120000_add_appointment`) names the column `no_show`.
15
+ * Prisma's generated client then targets the literal column `noShow`, which
16
+ * does not exist, and every `prisma.appointment.create()` (or any other
17
+ * write/read touching this field) fails at runtime.
18
+ *
19
+ * Spawns `appointment-create-probe.ts` via `tsx` (a real ESM process) rather
20
+ * than importing the generated client directly in this file — the generated
21
+ * client (`generated/client/client.ts`) uses `import.meta.url` and cannot
22
+ * load under this package's CJS `ts-jest` transform (the same constraint
23
+ * `prefixed-ids.db.test.ts` documents; see that file's header for why THAT
24
+ * test uses raw `pg` instead — this one needs the real Prisma runtime
25
+ * specifically to prove the `@map` targets the right column, which a raw-SQL
26
+ * probe cannot exercise since it never goes through Prisma's own column-name
27
+ * resolution).
28
+ *
29
+ * RED, reproduced by hand against this exact fixture before the fix (`git
30
+ * stash` the `@map("no_show")` line, `prisma generate`, re-run):
31
+ *
32
+ * PrismaClientKnownRequestError: The column `noShow of relation
33
+ * appointments` does not exist in the current database.
34
+ *
35
+ * GREEN, after `@map("no_show")` is restored: the probe's stdout is the
36
+ * created row, `noShow: true` included, and the process exits 0. Both runs
37
+ * are logged in this PR's Verification section.
38
+ */
39
+ import { spawnSync } from "node:child_process";
40
+ import { existsSync } from "node:fs";
41
+ import { join } from "node:path";
42
+
43
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
44
+
45
+ const DATABASE_DIR = join(__dirname, "..");
46
+ const PROBE_SCRIPT = join(__dirname, "fixtures", "appointment-create-probe.ts");
47
+
48
+ /** Walk up from DATABASE_DIR to find node_modules/.bin/tsx (worktree-safe —
49
+ * mirrors `database/inbox/__tests__/schema.test.ts`'s own tsx-via-pnpm-exec
50
+ * invocation, but resolves the binary directly since this call needs a
51
+ * scoped `env` — including a possibly-overridden DATABASE_URL — that `pnpm
52
+ * exec` would otherwise have to re-derive). */
53
+ function findTsxBin(): string {
54
+ let dir = DATABASE_DIR;
55
+ for (let i = 0; i < 5; i++) {
56
+ const candidate = join(dir, "node_modules", ".bin", "tsx");
57
+ if (existsSync(candidate)) return candidate;
58
+ dir = join(dir, "..");
59
+ }
60
+ throw new Error(`tsx binary not found searching up from ${DATABASE_DIR}.`);
61
+ }
62
+
63
+ describeDb(
64
+ "Appointment.noShow — live DB round trip via prisma.appointment.create() (#6162)",
65
+ () => {
66
+ it("creates and reads back an Appointment row, including noShow", () => {
67
+ const tsxBin = findTsxBin();
68
+ const result = spawnSync(tsxBin, [PROBE_SCRIPT], {
69
+ cwd: DATABASE_DIR,
70
+ encoding: "utf-8",
71
+ env: process.env,
72
+ });
73
+
74
+ // Asserting on exit status + a successful parse of stdout — rather
75
+ // than requiring empty stderr — is the full signal: a stray Node/tsx
76
+ // diagnostic (an experimental-feature banner, a deprecation warning)
77
+ // on an otherwise-successful run must not fail this test (code review
78
+ // on #6162). A genuine failure still surfaces loudly: `status` carries
79
+ // the exit code and `stderr` the probe's own logged message.
80
+ if (result.status !== 0) {
81
+ throw new Error(
82
+ `probe exited ${result.status}: ${result.stderr || "(no stderr)"}`,
83
+ );
84
+ }
85
+
86
+ const row = JSON.parse(result.stdout) as {
87
+ siteId: string;
88
+ workspaceId: string;
89
+ attendeeEmail: string;
90
+ status: string;
91
+ noShow: boolean;
92
+ noShowEventAt: string | null;
93
+ };
94
+ expect(row.siteId).toBe("wksp_00000000-0000-7000-8000-00000000f1f1");
95
+ // #6374 — the probe row is fenced like every other appointments row:
96
+ // `workspace_id` is NOT NULL and must EQUAL `site_id`. A probe that
97
+ // still wrote the pre-fence free-form site id would be rejected by
98
+ // `appointments_workspace_id_format` before this assertion ran.
99
+ expect(row.workspaceId).toBe(row.siteId);
100
+ expect(row.attendeeEmail).toBe("appointment-probe@example.invalid");
101
+ expect(row.status).toBe("confirmed");
102
+ expect(row.noShow).toBe(true);
103
+ expect(row.noShowEventAt).not.toBeNull();
104
+ });
105
+ },
106
+ );
@@ -0,0 +1,316 @@
1
+ /**
2
+ * @file artifact-content-store.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of roadmap D1/D7 (#5800) — the generalized `Artifact` and
5
+ * the workspace-scoped `Asset`. Requires DATABASE_URL pointing at a Postgres
6
+ * with the migrations applied (`prisma migrate deploy`); the whole suite SKIPS
7
+ * when DATABASE_URL is unset, matching `prefixed-ids.db.test.ts` and keeping the
8
+ * workspace Jest run DB-free.
9
+ *
10
+ * Raw SQL through `pg` for the same reason that file gives: the generated Prisma
11
+ * client is ESM-only and cannot load under this package's CJS ts-jest transform,
12
+ * and raw SQL is the sharper proof anyway — a constraint must hold on the path
13
+ * no application helper can guard.
14
+ *
15
+ * What it proves:
16
+ * 1. THE SURVIVAL PROPERTY (D1). An artifact produced inside an agent session
17
+ * outlives that session: deleting the session sets `session_id` to NULL and
18
+ * leaves the row, its versions, and its publish pointer intact. Before this
19
+ * migration the FK was `ON DELETE CASCADE` on a NOT NULL column, so the
20
+ * same delete destroyed the workspace's content. This is the whole point of
21
+ * "the session becomes provenance, the workspace owns the artifact".
22
+ * 2. The identity of a published page — `(workspace_id, content_type, locale,
23
+ * slug)` is unique, while unpublished drafts (slug NULL) are unconstrained,
24
+ * because NULLs never collide in a Postgres unique index.
25
+ * 3. `published_version_id` is an independent pointer: it is NOT `is_current`,
26
+ * and appending a newer draft tip does not move it.
27
+ * 4. `assets` is workspace-scoped with a unique `(workspace_id, key)` and a
28
+ * DB-minted `asset_` UUIDv7 id (ADR-0031).
29
+ */
30
+ import { randomUUID } from "node:crypto";
31
+
32
+ import { Client } from "pg";
33
+
34
+ import { DEFAULT_WORKSPACE_ID } from "../tenancy.js";
35
+
36
+ const describeDb = process.env.DATABASE_URL ? describe : describe.skip;
37
+
38
+ const V7_RE =
39
+ /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
40
+
41
+ /** A run-unique suffix so repeated local runs never collide on the unique key. */
42
+ const RUN = randomUUID().slice(0, 8);
43
+
44
+ describeDb("Artifact content store (roadmap D1/D7, live DB)", () => {
45
+ let db: Client;
46
+
47
+ beforeAll(async () => {
48
+ db = new Client({ connectionString: process.env.DATABASE_URL });
49
+ await db.connect();
50
+ });
51
+
52
+ afterAll(async () => {
53
+ await db.query(
54
+ `DELETE FROM "artifacts" WHERE "title" LIKE $1`,
55
+ [`5800-${RUN}%`],
56
+ );
57
+ await db.query(`DELETE FROM "agent_sessions" WHERE "title" LIKE $1`, [
58
+ `5800-${RUN}%`,
59
+ ]);
60
+ await db.query(`DELETE FROM "assets" WHERE "key" LIKE $1`, [
61
+ `5800-${RUN}%`,
62
+ ]);
63
+ await db.end();
64
+ });
65
+
66
+ describe("the session is provenance — deleting it never destroys content", () => {
67
+ it("sets session_id NULL and leaves the artifact, its versions and its publish pointer", async () => {
68
+ const sessionId = (
69
+ await db.query<{ id: string }>(
70
+ `INSERT INTO "agent_sessions" ("workspace_id", "title", "updated_at")
71
+ VALUES ($1, $2, now()) RETURNING "id"`,
72
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} session`],
73
+ )
74
+ ).rows[0]!.id;
75
+
76
+ const artifactId = (
77
+ await db.query<{ id: string }>(
78
+ `INSERT INTO "artifacts" ("session_id", "workspace_id", "title", "content_type", "locale", "slug", "updated_at")
79
+ VALUES ($1, $2, $3, 'text/page', 'en', $4, now()) RETURNING "id"`,
80
+ [
81
+ sessionId,
82
+ DEFAULT_WORKSPACE_ID,
83
+ `5800-${RUN} page`,
84
+ `5800-${RUN}-survivor`,
85
+ ],
86
+ )
87
+ ).rows[0]!.id;
88
+
89
+ const versionId = (
90
+ await db.query<{ id: string }>(
91
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
92
+ VALUES ($1, $2, '{"body":"v1"}'::jsonb, TRUE) RETURNING "id"`,
93
+ [artifactId, DEFAULT_WORKSPACE_ID],
94
+ )
95
+ ).rows[0]!.id;
96
+
97
+ await db.query(
98
+ `UPDATE "artifacts" SET "published_version_id" = $1 WHERE "id" = $2`,
99
+ [versionId, artifactId],
100
+ );
101
+
102
+ // The act under test: the producing session is deleted outright.
103
+ await db.query(`DELETE FROM "agent_sessions" WHERE "id" = $1`, [
104
+ sessionId,
105
+ ]);
106
+
107
+ const { rows } = await db.query<{
108
+ session_id: string | null;
109
+ workspace_id: string;
110
+ published_version_id: string | null;
111
+ }>(
112
+ `SELECT "session_id", "workspace_id", "published_version_id"
113
+ FROM "artifacts" WHERE "id" = $1`,
114
+ [artifactId],
115
+ );
116
+
117
+ expect(rows).toHaveLength(1);
118
+ expect(rows[0]!.session_id).toBeNull();
119
+ expect(rows[0]!.workspace_id).toBe(DEFAULT_WORKSPACE_ID);
120
+ expect(rows[0]!.published_version_id).toBe(versionId);
121
+
122
+ const versions = await db.query(
123
+ `SELECT "id" FROM "artifact_versions" WHERE "artifact_id" = $1`,
124
+ [artifactId],
125
+ );
126
+ expect(versions.rows).toHaveLength(1);
127
+ });
128
+
129
+ it("accepts an artifact with no session at all — the workspace owns it", async () => {
130
+ const { rows } = await db.query<{ id: string; session_id: string | null }>(
131
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
132
+ VALUES ($1, $2, 'text/page', 'en', now()) RETURNING "id", "session_id"`,
133
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} sessionless`],
134
+ );
135
+
136
+ expect(rows[0]!.session_id).toBeNull();
137
+ expect(rows[0]!.id).toMatch(/^artif_/);
138
+ });
139
+ });
140
+
141
+ describe("published identity", () => {
142
+ it("rejects a second artifact with the same (workspace, contentType, locale, slug)", async () => {
143
+ const slug = `5800-${RUN}-dupe`;
144
+ await db.query(
145
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
146
+ VALUES ($1, $2, 'text/page', 'en', $3, now())`,
147
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} first`, slug],
148
+ );
149
+
150
+ await expect(
151
+ db.query(
152
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
153
+ VALUES ($1, $2, 'text/page', 'en', $3, now())`,
154
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} second`, slug],
155
+ ),
156
+ ).rejects.toThrow(/duplicate key|unique/i);
157
+ });
158
+
159
+ it("admits many unpublished drafts — a NULL slug never collides", async () => {
160
+ for (const n of [1, 2, 3]) {
161
+ await db.query(
162
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
163
+ VALUES ($1, $2, 'text/page', 'en', now())`,
164
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} draft ${n}`],
165
+ );
166
+ }
167
+
168
+ const { rows } = await db.query<{ count: string }>(
169
+ `SELECT count(*)::text AS count FROM "artifacts"
170
+ WHERE "title" LIKE $1 AND "slug" IS NULL`,
171
+ [`5800-${RUN} draft %`],
172
+ );
173
+ expect(rows[0]!.count).toBe("3");
174
+ });
175
+
176
+ it("separates the same slug across locales", async () => {
177
+ const slug = `5800-${RUN}-locale`;
178
+ for (const locale of ["en", "fr"]) {
179
+ await db.query(
180
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "slug", "updated_at")
181
+ VALUES ($1, $2, 'text/page', $3, $4, now())`,
182
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} ${locale}`, locale, slug],
183
+ );
184
+ }
185
+
186
+ const { rows } = await db.query<{ count: string }>(
187
+ `SELECT count(*)::text AS count FROM "artifacts" WHERE "slug" = $1`,
188
+ [slug],
189
+ );
190
+ expect(rows[0]!.count).toBe("2");
191
+ });
192
+ });
193
+
194
+ describe("the publish pointer is not the draft tip", () => {
195
+ it("survives a newer is_current version", async () => {
196
+ const artifactId = (
197
+ await db.query<{ id: string }>(
198
+ `INSERT INTO "artifacts" ("workspace_id", "title", "content_type", "locale", "updated_at")
199
+ VALUES ($1, $2, 'text/page', 'en', now()) RETURNING "id"`,
200
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN} pointer`],
201
+ )
202
+ ).rows[0]!.id;
203
+
204
+ const published = (
205
+ await db.query<{ id: string }>(
206
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
207
+ VALUES ($1, $2, '{"body":"published"}'::jsonb, TRUE) RETURNING "id"`,
208
+ [artifactId, DEFAULT_WORKSPACE_ID],
209
+ )
210
+ ).rows[0]!.id;
211
+
212
+ await db.query(
213
+ `UPDATE "artifacts" SET "published_version_id" = $1 WHERE "id" = $2`,
214
+ [published, artifactId],
215
+ );
216
+
217
+ // A newer draft tip lands; the publish pointer must NOT follow it.
218
+ await db.query(
219
+ `UPDATE "artifact_versions" SET "is_current" = FALSE WHERE "artifact_id" = $1`,
220
+ [artifactId],
221
+ );
222
+ const draft = (
223
+ await db.query<{ id: string }>(
224
+ `INSERT INTO "artifact_versions" ("artifact_id", "workspace_id", "content", "is_current")
225
+ VALUES ($1, $2, '{"body":"draft"}'::jsonb, TRUE) RETURNING "id"`,
226
+ [artifactId, DEFAULT_WORKSPACE_ID],
227
+ )
228
+ ).rows[0]!.id;
229
+
230
+ const { rows } = await db.query<{ published_version_id: string | null }>(
231
+ `SELECT "published_version_id" FROM "artifacts" WHERE "id" = $1`,
232
+ [artifactId],
233
+ );
234
+ expect(rows[0]!.published_version_id).toBe(published);
235
+ expect(rows[0]!.published_version_id).not.toBe(draft);
236
+ });
237
+ });
238
+
239
+ describe("Asset (D7)", () => {
240
+ it("mints an asset_ UUIDv7 id and stores the bytes", async () => {
241
+ const { rows } = await db.query<{ id: string; size: number }>(
242
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
243
+ VALUES ($1, $2, 'logo.png', 'image/png', $3, 4) RETURNING "id", "size"`,
244
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN}-logo`, Buffer.from([1, 2, 3, 4])],
245
+ );
246
+
247
+ expect(rows[0]!.id).toMatch(/^asset_/);
248
+ expect(rows[0]!.id.slice("asset_".length)).toMatch(V7_RE);
249
+ expect(rows[0]!.size).toBe(4);
250
+ });
251
+
252
+ it("rejects a second asset with the same (workspace, key)", async () => {
253
+ const key = `5800-${RUN}-dupe-key`;
254
+ await db.query(
255
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
256
+ VALUES ($1, $2, 'a.png', 'image/png', $3, 1)`,
257
+ [DEFAULT_WORKSPACE_ID, key, Buffer.from([1])],
258
+ );
259
+
260
+ await expect(
261
+ db.query(
262
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
263
+ VALUES ($1, $2, 'b.png', 'image/png', $3, 1)`,
264
+ [DEFAULT_WORKSPACE_ID, key, Buffer.from([2])],
265
+ ),
266
+ ).rejects.toThrow(/duplicate key|unique/i);
267
+ });
268
+
269
+ it("rejects a malformed workspace_id (ADR-0031 CHECK)", async () => {
270
+ await expect(
271
+ db.query(
272
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
273
+ VALUES ('not-a-workspace-id', $1, 'a.png', 'image/png', $2, 1)`,
274
+ [`5800-${RUN}-bad-scope`, Buffer.from([1])],
275
+ ),
276
+ ).rejects.toThrow(/assets_workspace_id_format|check constraint/i);
277
+ });
278
+
279
+ // #6092 (S4/D10) — title, description and tags. `tags` stores SLUGS, never
280
+ // the human labels the lightbox renders (D3): 'ad-creative' and
281
+ // 'user-uploaded' are the exact pair the demo's first asset carries
282
+ // (demo.html:357).
283
+ it("stores and returns two tags", async () => {
284
+ const { rows } = await db.query<{ tags: string[] }>(
285
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size", "tags")
286
+ VALUES ($1, $2, 'tagged.png', 'image/png', $3, 1, $4) RETURNING "tags"`,
287
+ [
288
+ DEFAULT_WORKSPACE_ID,
289
+ `5800-${RUN}-tagged`,
290
+ Buffer.from([1]),
291
+ ["ad-creative", "user-uploaded"],
292
+ ],
293
+ );
294
+
295
+ expect(rows[0]!.tags).toEqual(["ad-creative", "user-uploaded"]);
296
+ expect(rows[0]!.tags).toHaveLength(2);
297
+ });
298
+
299
+ it("defaults title, description and tags when none of the three columns is named", async () => {
300
+ const { rows } = await db.query<{
301
+ title: string | null;
302
+ description: string | null;
303
+ tags: string[];
304
+ }>(
305
+ `INSERT INTO "assets" ("workspace_id", "key", "filename", "content_type", "bytes", "size")
306
+ VALUES ($1, $2, 'untagged.png', 'image/png', $3, 1)
307
+ RETURNING "title", "description", "tags"`,
308
+ [DEFAULT_WORKSPACE_ID, `5800-${RUN}-untagged`, Buffer.from([1])],
309
+ );
310
+
311
+ expect(rows[0]!.title).toBeNull();
312
+ expect(rows[0]!.description).toBeNull();
313
+ expect(rows[0]!.tags).toEqual([]);
314
+ });
315
+ });
316
+ });