@cosmicdrift/kumiko-samples 0.387.0 → 0.389.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 (274) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +12 -0
  4. package/packages/bundled-features/src/agent-tools/index.ts +1 -1
  5. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +48 -32
  6. package/packages/bundled-features/src/audit/changes.json +12 -0
  7. package/packages/bundled-features/src/audit/feature.ts +1 -0
  8. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/changes.json +12 -0
  10. package/packages/bundled-features/src/auth-email-password/handlers/request-account-unlock.write.ts +1 -0
  11. package/packages/bundled-features/src/auth-email-password/handlers/request-email-verification.write.ts +1 -0
  12. package/packages/bundled-features/src/auth-email-password/handlers/request-password-reset.write.ts +1 -0
  13. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +4 -0
  14. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +8 -0
  15. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +16 -3
  16. package/packages/bundled-features/src/auth-email-password/signup-activation.ts +2 -0
  17. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +8 -2
  18. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +23 -21
  19. package/packages/bundled-features/src/auth-email-password/web/default-auth-pages-gate.tsx +6 -3
  20. package/packages/bundled-features/src/auth-email-password/web/forgot-password-screen.tsx +11 -1
  21. package/packages/bundled-features/src/auth-email-password/web/index.ts +5 -0
  22. package/packages/bundled-features/src/auth-email-password/web/login-screen.tsx +16 -5
  23. package/packages/bundled-features/src/auth-email-password/web/reset-password-screen.tsx +7 -1
  24. package/packages/bundled-features/src/auth-email-password/web/return-path.ts +35 -0
  25. package/packages/bundled-features/src/auth-email-password/web/session.tsx +6 -1
  26. package/packages/bundled-features/src/auth-email-password/web/signup-complete-screen.tsx +11 -3
  27. package/packages/bundled-features/src/auth-email-password/web/signup-screen.tsx +12 -2
  28. package/packages/bundled-features/src/auth-mfa/changes.json +12 -0
  29. package/packages/bundled-features/src/auth-mfa/config.ts +3 -7
  30. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  31. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  32. package/packages/bundled-features/src/auth-mfa/mfa-status-checker.ts +8 -5
  33. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  34. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  35. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  36. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  37. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  38. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  39. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  40. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  41. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  42. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  43. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  44. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  45. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  46. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  47. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  48. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  49. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  50. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  51. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  52. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  53. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  54. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  55. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  56. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  57. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  58. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  59. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  60. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  61. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  62. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  63. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  64. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  65. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  66. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  67. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  68. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  69. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  70. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  71. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +25 -8
  72. package/packages/bundled-features/src/cap-counter/changes.json +30 -0
  73. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  74. package/packages/bundled-features/src/cap-counter/feature.ts +1 -0
  75. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  76. package/packages/bundled-features/src/cap-overview/feature.ts +1 -0
  77. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  78. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  79. package/packages/bundled-features/src/config/changes.json +18 -0
  80. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  81. package/packages/bundled-features/src/config/handlers/set.write.ts +19 -2
  82. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  83. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  84. package/packages/bundled-features/src/crypto-shredding/changes.json +18 -0
  85. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +11 -10
  86. package/packages/bundled-features/src/custom-fields/lib/parse-serialized-field.ts +1 -1
  87. package/packages/bundled-features/src/delivery/changes.json +42 -0
  88. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  89. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  90. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  91. package/packages/bundled-features/src/delivery/feature.ts +1 -0
  92. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  93. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  94. package/packages/bundled-features/src/delivery/index.ts +5 -0
  95. package/packages/bundled-features/src/delivery/system-channel-context.ts +33 -0
  96. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  97. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  98. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  99. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  100. package/packages/bundled-features/src/document-ingest-foundation/append-missing-extract-event.ts +84 -0
  101. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  102. package/packages/bundled-features/src/document-ingest-foundation/events.ts +15 -0
  103. package/packages/bundled-features/src/document-ingest-foundation/index.ts +11 -0
  104. package/packages/bundled-features/src/feature-toggles/feature.ts +1 -0
  105. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  106. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  107. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  108. package/packages/bundled-features/src/files/README.md +1 -1
  109. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  110. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  111. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  112. package/packages/bundled-features/src/host-routing/README.md +1 -1
  113. package/packages/bundled-features/src/host-routing/changes.json +12 -0
  114. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  115. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  116. package/packages/bundled-features/src/host-routing/validate-options.ts +5 -0
  117. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  118. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +1 -0
  119. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  120. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  121. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  122. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  123. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  124. package/packages/bundled-features/src/jobs/changes.json +13 -0
  125. package/packages/bundled-features/src/jobs/feature.ts +15 -4
  126. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  127. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  128. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  129. package/packages/bundled-features/src/ledger/web/index.ts +1 -1
  130. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  131. package/packages/bundled-features/src/legal-pages/seed.ts +5 -2
  132. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  133. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  134. package/packages/bundled-features/src/mail-transport-smtp/changes.json +6 -0
  135. package/packages/bundled-features/src/mail-transport-smtp/config-keys.ts +1 -1
  136. package/packages/bundled-features/src/mail-transport-smtp/feature.ts +9 -11
  137. package/packages/bundled-features/src/managed-pages/changes.json +6 -0
  138. package/packages/bundled-features/src/managed-pages/constants.ts +1 -0
  139. package/packages/bundled-features/src/managed-pages/feature.ts +3 -1
  140. package/packages/bundled-features/src/managed-pages/index.ts +1 -0
  141. package/packages/bundled-features/src/notes-history/handlers/add-note.write.ts +1 -1
  142. package/packages/bundled-features/src/personal-access-tokens/feature.ts +5 -1
  143. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  144. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  145. package/packages/bundled-features/src/secrets/changes.json +6 -0
  146. package/packages/bundled-features/src/secrets/write-gate.ts +9 -2
  147. package/packages/bundled-features/src/sessions/changes.json +7 -0
  148. package/packages/bundled-features/src/sessions/feature.ts +8 -2
  149. package/packages/bundled-features/src/sessions/index.ts +1 -1
  150. package/packages/bundled-features/src/sessions/session-callbacks.ts +1 -9
  151. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  152. package/packages/bundled-features/src/shared/index.ts +1 -1
  153. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  154. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +24 -2
  155. package/packages/bundled-features/src/shared/run-in-sub-transaction.ts +5 -5
  156. package/packages/bundled-features/src/step-dispatcher/changes.json +12 -0
  157. package/packages/bundled-features/src/step-dispatcher/deliver-dispatch-request.ts +17 -3
  158. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  159. package/packages/bundled-features/src/step-dispatcher/mail-runner.ts +2 -1
  160. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  161. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  162. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  163. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  164. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  165. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  166. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  167. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  168. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  169. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  170. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  171. package/packages/bundled-features/src/tags/feature.ts +1 -0
  172. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  173. package/packages/bundled-features/src/template-resolver/changes.json +84 -0
  174. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  175. package/packages/bundled-features/src/template-resolver/document-ops.ts +20 -1
  176. package/packages/bundled-features/src/template-resolver/feature.ts +4 -4
  177. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  178. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  179. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  180. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  181. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  182. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +36 -11
  183. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  184. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +45 -10
  185. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +1 -1
  186. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +22 -19
  187. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  188. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  189. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  190. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  191. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  192. package/packages/bundled-features/src/template-resolver/seeding.ts +42 -17
  193. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +74 -12
  194. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  195. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  196. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  197. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  198. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  199. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  200. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  201. package/packages/bundled-features/src/template-resolver/web/qualified-names.ts +1 -0
  202. package/packages/bundled-features/src/tenant/changes.json +6 -0
  203. package/packages/bundled-features/src/tenant/feature.ts +1 -0
  204. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  205. package/packages/bundled-features/src/tenant/handlers/team-list.query.ts +3 -3
  206. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  207. package/packages/bundled-features/src/tenant-lifecycle/changes.json +12 -0
  208. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  209. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  210. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +35 -1
  211. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  212. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  213. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  214. package/packages/bundled-features/src/user/feature.ts +1 -0
  215. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  216. package/packages/bundled-features/src/user-data-rights/feature.ts +1 -0
  217. package/packages/bundled-features/src/user-data-rights/index.ts +6 -1
  218. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  219. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  220. package/packages/bundled-features/src/user-data-rights/web/constants.ts +6 -0
  221. package/packages/bundled-features/src/user-data-rights/web/index.ts +1 -0
  222. package/packages/bundled-features/src/waitlist/changes.json +36 -0
  223. package/packages/bundled-features/src/waitlist/feature.ts +1 -0
  224. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  225. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +28 -9
  226. package/packages/bundled-features/src/waitlist/import.ts +28 -3
  227. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +24 -4
  228. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  229. package/packages/bundled-features/src/waitlist/options.ts +5 -1
  230. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +1 -1
  231. package/samples/apps/styleguide/src/app/client.tsx +2 -0
  232. package/samples/apps/styleguide/src/app/seed.ts +6 -4
  233. package/samples/apps/styleguide/src/app/server.ts +4 -1
  234. package/samples/apps/styleguide/src/features/widgets/feature.ts +15 -4
  235. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  236. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  237. package/samples/apps/styleguide/src/features/workboard/feature.ts +185 -0
  238. package/samples/apps/styleguide/src/features/workboard/i18n.ts +27 -0
  239. package/samples/apps/styleguide/src/features/workboard/seed.ts +121 -0
  240. package/samples/apps/styleguide/src/features/workboard/web.ts +13 -0
  241. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +2 -0
  242. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +9 -0
  243. package/samples/apps/use-all-bundled/bin/main.ts +2 -0
  244. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  245. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  246. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  247. package/samples/apps/use-all-bundled/feature-manifest.json +136 -3
  248. package/samples/apps/use-all-bundled/kumiko/schema.ts +2 -0
  249. package/samples/apps/use-all-bundled/kumiko.config.ts +2 -0
  250. package/samples/apps/use-all-bundled/package.json +2 -1
  251. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +2 -0
  252. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +2 -0
  253. package/samples/apps/use-all-bundled/src/bundled-lists.ts +15 -0
  254. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  255. package/samples/recipes/auth-mfa/README.md +5 -0
  256. package/samples/recipes/auth-mfa/package.json +5 -2
  257. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  258. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  259. package/samples/recipes/env-composition/README.md +3 -1
  260. package/samples/recipes/env-composition/src/env.ts +2 -0
  261. package/samples/recipes/golden-path/src/feature.ts +2 -1
  262. package/samples/recipes/legal-pages/README.md +1 -1
  263. package/samples/recipes/lifecycle-hooks/README.md +4 -0
  264. package/samples/recipes/lifecycle-hooks/src/feature.ts +26 -1
  265. package/samples/recipes/list-table-options/README.md +50 -0
  266. package/samples/recipes/list-table-options/package.json +11 -0
  267. package/samples/recipes/list-table-options/src/feature.ts +221 -0
  268. package/samples/recipes/pdf-audit-export/src/feature.ts +2 -1
  269. package/samples/recipes/rate-limiting/README.md +18 -0
  270. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  271. package/samples/recipes/state-machine/src/events.ts +3 -2
  272. package/samples/recipes/tenant-isolation/README.md +1 -0
  273. package/samples/recipes/tenant-isolation/src/feature.ts +36 -0
  274. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -0,0 +1,224 @@
1
+ import {
2
+ useLocale,
3
+ useMutation,
4
+ usePrimitives,
5
+ useQuery,
6
+ useTranslation,
7
+ } from "@cosmicdrift/kumiko-renderer";
8
+ import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
9
+ import { type ReactNode, useState } from "react";
10
+ import type { BillingPlansResult, PlanSwitchPreview } from "../types.ts";
11
+ import {
12
+ type CheckoutConsentPayload,
13
+ CONSENT_TEXT_OUTDATED_CODE,
14
+ } from "./checkout-consent-dialog.tsx";
15
+ import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.ts";
16
+ import { resolveConsentLocale } from "./consumer-protection/consent-locale.ts";
17
+ import { intervalKey } from "./plan-price.ts";
18
+
19
+ type ConsumerProtectionView = NonNullable<BillingPlansResult["consumerProtection"]>;
20
+
21
+ type PlanSwitchPreviewReply = {
22
+ readonly preview: PlanSwitchPreview;
23
+ readonly target: {
24
+ readonly unitAmount: number;
25
+ readonly currency: string;
26
+ readonly interval: string | null;
27
+ readonly intervalCount: number | null;
28
+ };
29
+ };
30
+
31
+ export type PlanSwitchConfirmDialogProps = {
32
+ readonly open: boolean;
33
+ readonly onOpenChange: (open: boolean) => void;
34
+ readonly tier: string;
35
+ readonly fromPlanName: string;
36
+ readonly toPlanName: string;
37
+ readonly consumerProtection?: ConsumerProtectionView;
38
+ readonly onApplied: () => void;
39
+ readonly onConsentTextOutdated: () => void;
40
+ };
41
+
42
+ // kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
43
+ export function PlanSwitchConfirmDialog({
44
+ open,
45
+ onOpenChange,
46
+ tier,
47
+ fromPlanName,
48
+ toPlanName,
49
+ consumerProtection,
50
+ onApplied,
51
+ onConsentTextOutdated,
52
+ }: PlanSwitchConfirmDialogProps): ReactNode {
53
+ const t = useTranslation();
54
+ const locale = useLocale().locale();
55
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
56
+ const previewQuery = useQuery<PlanSwitchPreviewReply>(
57
+ SubscriptionFoundationQueries.planSwitchPreview,
58
+ { tier },
59
+ );
60
+ const switchMutation = useMutation<{ readonly applied: true }>(
61
+ SubscriptionFoundationHandlers.switchPlan,
62
+ );
63
+ const [earlyPerformance, setEarlyPerformance] = useState(false);
64
+ const [withdrawalLoss, setWithdrawalLoss] = useState(false);
65
+ const [submitting, setSubmitting] = useState(false);
66
+ const [errorText, setErrorText] = useState<string | null>(null);
67
+
68
+ const consentLocale = resolveConsentLocale(locale);
69
+ const texts = consumerProtection?.consentTexts[consentLocale];
70
+ const legalLinks = consumerProtection?.legalLinks[consentLocale];
71
+ const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
72
+
73
+ async function confirm(): Promise<void> {
74
+ setSubmitting(true);
75
+ setErrorText(null);
76
+ const consent: CheckoutConsentPayload | undefined =
77
+ texts === undefined
78
+ ? undefined
79
+ : {
80
+ earlyPerformanceRequested: true,
81
+ withdrawalLossAcknowledged: true,
82
+ consentTextVersion: texts.consentTextVersion,
83
+ locale: consentLocale,
84
+ };
85
+ const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
86
+ if (result.isSuccess) {
87
+ onApplied();
88
+ return;
89
+ }
90
+ setSubmitting(false);
91
+ setErrorText(t(result.error.i18nKey, result.error.i18nParams));
92
+ if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
93
+ setEarlyPerformance(false);
94
+ setWithdrawalLoss(false);
95
+ onConsentTextOutdated();
96
+ }
97
+ }
98
+
99
+ const reply = previewQuery.data;
100
+ const money = (amount: number, currency: string) => formatMoney(amount, currency, locale);
101
+ const recurringKey = reply === null ? undefined : intervalKey(reply.target);
102
+ const recurringPrice =
103
+ reply === null
104
+ ? ""
105
+ : `${money(reply.target.unitAmount, reply.target.currency)}${
106
+ recurringKey === undefined
107
+ ? ""
108
+ : ` ${t(recurringKey, { count: new Intl.NumberFormat(locale).format(reply.target.intervalCount ?? 1) })}`
109
+ }`;
110
+
111
+ return (
112
+ <Modal
113
+ open={open}
114
+ onOpenChange={onOpenChange}
115
+ title={t("billing-foundation.planSwitch.title")}
116
+ testId="plan-switch-confirm-dialog"
117
+ >
118
+ <div className="flex flex-col gap-4">
119
+ <Text>
120
+ {t("billing-foundation.planSwitch.summary", { from: fromPlanName, to: toPlanName })}
121
+ </Text>
122
+ {previewQuery.error !== null && (
123
+ <Banner variant="error" testId="plan-switch-preview-error">
124
+ {t(previewQuery.error.i18nKey, previewQuery.error.i18nParams)}
125
+ </Banner>
126
+ )}
127
+ {previewQuery.loading && reply === null && (
128
+ <Banner variant="loading" testId="plan-switch-preview-loading">
129
+ {t("kumiko.widget.loading")}
130
+ </Banner>
131
+ )}
132
+ {reply !== null && (
133
+ <div className="flex flex-col gap-1" data-testid="plan-switch-summary">
134
+ <Text>
135
+ {t("billing-foundation.planSwitch.dueNow", {
136
+ amount: money(reply.preview.amountDueNow, reply.preview.currency),
137
+ })}
138
+ {reply.preview.tax !== null &&
139
+ ` (${t("billing-foundation.planSwitch.includesTax", {
140
+ tax: money(reply.preview.tax, reply.preview.currency),
141
+ })})`}
142
+ </Text>
143
+ <Text variant="muted">
144
+ {t("billing-foundation.planSwitch.credit", {
145
+ amount: money(reply.preview.credit, reply.preview.currency),
146
+ })}
147
+ </Text>
148
+ <Text variant="muted">
149
+ {t("billing-foundation.planSwitch.recurring", {
150
+ amount: recurringPrice,
151
+ })}
152
+ </Text>
153
+ <Text variant="muted">{t("billing-foundation.planSwitch.effective")}</Text>
154
+ <Text variant="muted">{t("billing-foundation.planSwitch.pendingNote")}</Text>
155
+ </div>
156
+ )}
157
+ {legalLinks !== undefined && (
158
+ <div className="flex flex-wrap gap-4">
159
+ <Link href={legalLinks.terms} target="_blank">
160
+ {t("billing-foundation.consent.link.terms")}
161
+ </Link>
162
+ <Link href={legalLinks.withdrawal} target="_blank">
163
+ {t("billing-foundation.consent.link.withdrawal")}
164
+ </Link>
165
+ <Link href={legalLinks.privacy} target="_blank">
166
+ {t("billing-foundation.consent.link.privacy")}
167
+ </Link>
168
+ </div>
169
+ )}
170
+ {texts !== undefined && (
171
+ <>
172
+ <Field
173
+ id="plan-switch-early-performance"
174
+ label={texts.earlyPerformance}
175
+ layout="inline"
176
+ >
177
+ <Input
178
+ kind="boolean"
179
+ id="plan-switch-early-performance"
180
+ name="plan-switch-early-performance"
181
+ value={earlyPerformance}
182
+ onChange={setEarlyPerformance}
183
+ disabled={submitting}
184
+ />
185
+ </Field>
186
+ <Field id="plan-switch-withdrawal-loss" label={texts.withdrawalLoss} layout="inline">
187
+ <Input
188
+ kind="boolean"
189
+ id="plan-switch-withdrawal-loss"
190
+ name="plan-switch-withdrawal-loss"
191
+ value={withdrawalLoss}
192
+ onChange={setWithdrawalLoss}
193
+ disabled={submitting}
194
+ />
195
+ </Field>
196
+ </>
197
+ )}
198
+ {errorText !== null && (
199
+ <Banner variant="error" testId="plan-switch-confirm-error">
200
+ {errorText}
201
+ </Banner>
202
+ )}
203
+ <div className="flex justify-end gap-2">
204
+ <Button
205
+ variant="secondary"
206
+ disabled={submitting}
207
+ onClick={() => onOpenChange(false)}
208
+ testId="plan-switch-confirm-cancel"
209
+ >
210
+ {t("billing-foundation.consent.back")}
211
+ </Button>
212
+ <Button
213
+ disabled={!consentGiven || reply === null || submitting}
214
+ loading={submitting}
215
+ onClick={confirm}
216
+ testId="plan-switch-confirm-order"
217
+ >
218
+ {t("billing-foundation.planSwitch.order")}
219
+ </Button>
220
+ </div>
221
+ </div>
222
+ </Modal>
223
+ );
224
+ }
@@ -350,6 +350,21 @@ function bookCalendarDelta(
350
350
  );
351
351
  }
352
352
 
353
+ // Asked after a missed or lost claim. A released tombstone committed since the first read does not
354
+ // count: the caller's own tombstone insert then conflicts with it, and the retry claims that row.
355
+ async function hasSettledTombstone(
356
+ txDb: TenantDb,
357
+ user: SessionUser,
358
+ id: string,
359
+ ): Promise<boolean> {
360
+ const settled = await txDb.fetchOne<CapReservationRow>(capReservationsTable, {
361
+ id,
362
+ tenantId: user.tenantId,
363
+ settledAt: { ne: null },
364
+ });
365
+ return settled !== undefined;
366
+ }
367
+
353
368
  async function settleInTransaction(
354
369
  txDb: TenantDb,
355
370
  user: SessionUser,
@@ -386,12 +401,10 @@ async function settleInTransaction(
386
401
  );
387
402
  return booked;
388
403
  }
389
- // The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
390
- if (await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow)) {
391
- return NOTHING_TO_RELEASE;
392
- }
404
+ if (await hasSettledTombstone(txDb, user, id)) return NOTHING_TO_RELEASE;
393
405
  // Row swept or released: its amount was given back, so the full actual amount is booked and a
394
- // tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
406
+ // tombstone makes a repeated settle a no-op. A parallel settle or consume inserting first fails
407
+ // this insert.
395
408
  // Booking first: a lost counter race returns a result instead of throwing, so the transaction
396
409
  // commits and a tombstone written before it would turn the retry into a no-op.
397
410
  const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount);
@@ -473,7 +486,11 @@ async function consumeClaimedRow(
473
486
  if (!booked.isSuccess) return booked;
474
487
  await txDb.updateMany(
475
488
  capReservationsTable,
476
- { consumedAmount: claimed.consumedAmount + options.units },
489
+ {
490
+ consumedAmount: claimed.consumedAmount + options.units,
491
+ // Keep the tombstone alive past its last consume, else a later consume would start a fresh one and double count.
492
+ expiresAt: Temporal.Now.instant().add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
493
+ },
477
494
  ownRow,
478
495
  );
479
496
  return booked;
@@ -493,7 +510,7 @@ async function consumeClaimedRow(
493
510
  }
494
511
 
495
512
  // Row already expired away: the units are booked directly and a released tombstone remembers them
496
- // so a later settle subtracts them. A parallel consume inserting first fails this insert.
513
+ // so a later settle subtracts them. A parallel consume or settle inserting first fails this insert.
497
514
  async function consumeWithoutRow(
498
515
  txDb: TenantDb,
499
516
  user: SessionUser,
@@ -538,7 +555,7 @@ async function consumeInTransaction(
538
555
  }
539
556
  const claimed = open && (await claimReservationRow(txDb, user, open));
540
557
  if (claimed) return consumeClaimedRow(txDb, user, claimed, options);
541
- if (await txDb.fetchOne<CapReservationRow>(capReservationsTable, ownRow)) {
558
+ if (await hasSettledTombstone(txDb, user, id)) {
542
559
  throw new CapReservationSettledError(options.capName);
543
560
  }
544
561
  return consumeWithoutRow(txDb, user, id, options);
@@ -1,4 +1,34 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "fix",
5
+ "title": "Settle and consume racing on a swept reservation book their units",
6
+ "detail": "After the sweep deleted a reservation's row, a consume could commit its released tombstone between the two reads of a parallel settle or consume. That call took the released tombstone for a settled one, so settle reported success without booking its amount and a second consume was rejected with cap_reservation_settled. Only a settled tombstone counts as settled now; a released one runs into the insert conflict and the retry books against it."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "fix",
11
+ "title": "Settle after a sweep survives a lost counter race",
12
+ "detail": "The amount is booked first and the tombstone written afterwards, so a version conflict on the counter retries the whole settle instead of leaving a tombstone that turns the retry into a no-op and drops the usage from the cap."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Consume on a released reservation extends its tombstone",
18
+ "detail": "Every consume against a released reservation tombstone now pushes its expiry out, so a long-running job that consumes across the sweep can no longer lose the tombstone and have its earlier units counted twice at settle."
19
+ },
20
+ {
21
+ "version": "0.388.0",
22
+ "type": "fix",
23
+ "title": "Misconfigured cap period fails on every enforcement",
24
+ "detail": "A periodEndIso that is unparsable or not after periodStartIso now throws on each enforceCap or reservation call instead of only once the hard cap is reached, so a bad period no longer turns into a 500 at the limit instead of cap_exceeded."
25
+ },
26
+ {
27
+ "version": "0.388.0",
28
+ "type": "fix",
29
+ "title": "Stock cap lock covers the counted rows, not the error code",
30
+ "detail": "withStockCap serializes on the table and where filter that are counted, so two handlers capping the same rows under different codes can no longer both pass the check in parallel and exceed the cap together."
31
+ },
2
32
  {
3
33
  "version": "0.378.0",
4
34
  "type": "fix",
@@ -71,8 +71,9 @@ export function assertBelowHardCap(
71
71
  },
72
72
  ): void {
73
73
  const tolerance = CAP_TOLERANCES[cap.profile];
74
+ // Validated on every call so a misconfigured period fails deterministically, not only at the limit.
75
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
74
76
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
75
- const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
76
77
  throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
77
78
  ...(resetAt !== undefined && { resetAt }),
78
79
  ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
@@ -71,6 +71,7 @@ export function createCapCounterFeature() {
71
71
  return defineFeature(
72
72
  CAP_COUNTER_FEATURE,
73
73
  (r) => {
74
+ r.adoptsBundledLists();
74
75
  r.describe(
75
76
  "Tracks per-tenant usage against configurable limits using two complementary storage models: calendar-period counters (one projection row per tenant/capName/period, reset implicitly by period rollover) and rolling-window counters (append-only event stream, no projection). Use `enforceCap` / `enforceRollingCap` (or the `withCapEnforcement` / `withRollingCapEnforcement` handler wrappers) in your write-handlers to check limits with soft-warn and hard-block tolerances; call `enforceCapAndMaybeNotify` when you also want to trigger a delivery notification on soft-threshold hits.",
76
77
  );
@@ -40,6 +40,13 @@ export type StockCapSpec<TCaps> = {
40
40
  readonly field: string;
41
41
  };
42
42
 
43
+ // Lock on the counted set, not on `code`: two handlers capping the same rows under
44
+ // different codes must still serialize against each other.
45
+ function countedSetKey(spec: Pick<StockCapSpec<unknown>, "table" | "where">): string {
46
+ const where = Object.entries(spec.where ?? {}).sort(([a], [b]) => a.localeCompare(b));
47
+ return `${spec.table.tableName}:${JSON.stringify(where)}`;
48
+ }
49
+
43
50
  export type StockCapGuard<TCaps> = {
44
51
  readonly checkStockCap: (
45
52
  db: TenantDb,
@@ -81,7 +88,7 @@ export function createStockCapGuard<TCaps>(
81
88
  // ctx.db is the handler's transaction, so the xact lock releases with its commit or
82
89
  // rollback. A handler wrapped in several caps must list them in the same order
83
90
  // everywhere; Postgres aborts one side of an inverse order with a deadlock error.
84
- await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec.code);
91
+ await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, countedSetKey(spec));
85
92
  const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
86
93
  return failure ?? handler.handler(event, ctx);
87
94
  },
@@ -61,6 +61,7 @@ export function createCapOverviewFeature(opts: CreateCapOverviewOptions): Featur
61
61
  }
62
62
 
63
63
  return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
64
+ r.adoptsBundledLists();
64
65
  r.describe(
65
66
  "Read-only visibility into per-tenant tier assignment and cap usage. SystemAdmin gets a platform-wide tenant list with usage bars; tenant admins (or the roles in `usageVisibleTo`) get their own tenant's usage as dashboard cards. Reads tier-engine, billing-foundation, and tenant data plus app-owned usage tables via caller-supplied CapSpec callbacks — never writes.",
66
67
  );
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.388.0",
4
+ "type": "fix",
5
+ "title": "Corrected description of an email channel without app transport",
6
+ "detail": "Without an app transport only notifications declared with tenantTransport true are delivered; all others fail with missing_credentials, so such apps must mark every outgoing notification or keep an app transport."
7
+ },
2
8
  {
3
9
  "version": "0.376.0",
4
10
  "type": "improvement",
@@ -12,7 +12,7 @@ export function createChannelEmailFeature(options: EmailChannelOptions) {
12
12
 
13
13
  const feature = defineFeature("channel-email", (r) => {
14
14
  r.describe(
15
- "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
15
+ "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
16
16
  );
17
17
  r.uiHints({
18
18
  displayLabel: "Email Channel",
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "config:write:set passes readCascade to validators",
6
+ "detail": "The set handler binds readCascade to the config resolver, the tenant-scoped db and the secrets reader, so validators see the same cascade as the value queries."
7
+ },
8
+ {
9
+ "version": "0.388.0",
10
+ "type": "improvement",
11
+ "title": "config:query:values and config:query:cascade accept a read scope",
12
+ "detail": "Both queries take an optional scope (system, tenant, user). With scope system the cascade of a tenant or user key is evaluated at system level, so the system row is returned even when the caller's own tenant has a row for the key. Only a SystemAdmin gets the narrowed read; other callers keep the normal cascade. Inherited-value redaction still applies. Without scope the behavior is unchanged."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "Remove unreachable branch in configRowHoldsValue",
18
+ "detail": "The undefined key definition is now handled together with the plaintext comparison, dropping a dead return without changing behavior."
19
+ },
2
20
  {
3
21
  "version": "0.364.0",
4
22
  "type": "fix",
@@ -1,13 +1,14 @@
1
1
  import {
2
2
  type ConfigCascade,
3
3
  type ConfigCascadeLevel,
4
+ ConfigScopes,
4
5
  defineQueryHandler,
5
6
  isEncryptedAtRest,
6
7
  } from "@cosmicdrift/kumiko-framework/engine";
7
8
  import * as z from "zod";
8
9
  import { requireConfigResolver, requireSystemDb } from "../feature.ts";
9
10
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.ts";
10
- import { hasConfigAccess } from "../write-helpers.ts";
11
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.ts";
11
12
 
12
13
  export const cascadeQuery = defineQueryHandler({
13
14
  name: "cascade",
@@ -15,6 +16,7 @@ export const cascadeQuery = defineQueryHandler({
15
16
  "Shows, per config key the caller may read, the value at every cascade level (user, tenant, system, app override, default) and which level won; use it to explain where a setting's current value comes from.",
16
17
  schema: z.object({
17
18
  keys: z.array(z.string()).optional(),
19
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
18
20
  }),
19
21
  access: {
20
22
  openToAll: {
@@ -47,7 +49,7 @@ export const cascadeQuery = defineQueryHandler({
47
49
  const keyDef = allKeys.get(key);
48
50
  if (!keyDef) continue;
49
51
  if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
50
- keyDefs.set(key, keyDef);
52
+ keyDefs.set(key, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
51
53
  filteredKeys.push(key);
52
54
  }
53
55
 
@@ -18,7 +18,7 @@ import {
18
18
  writeFailure,
19
19
  } from "@cosmicdrift/kumiko-framework/errors";
20
20
  import * as z from "zod";
21
- import { requireConfigEncryption, requireSystemDb } from "../feature.ts";
21
+ import { requireConfigEncryption, requireConfigResolver, requireSystemDb } from "../feature.ts";
22
22
  import { MASKED } from "../read-redaction.ts";
23
23
  import { configValueEntity, configValuesTable } from "../table.ts";
24
24
  import {
@@ -106,13 +106,30 @@ export const setWrite = defineWriteHandler({
106
106
  if (patternError) return writeFailure(patternError);
107
107
 
108
108
  if (keyDef.validate) {
109
+ const validateDb = scopeTenantDbToTenant(db, tenantId);
109
110
  try {
110
111
  await keyDef.validate(event.payload.value, {
111
112
  key: event.payload.key,
112
113
  scope,
113
114
  tenantId,
114
115
  userId,
115
- db: scopeTenantDbToTenant(db, tenantId),
116
+ db: validateDb,
117
+ readCascade: (qualifiedKey) => {
118
+ const cascadeKeyDef = ctx.registry.getConfigKey(qualifiedKey);
119
+ if (!cascadeKeyDef) {
120
+ throw new InternalError({
121
+ message: `[config:write:set] readCascade: unknown config key "${qualifiedKey}"`,
122
+ });
123
+ }
124
+ return requireConfigResolver(ctx, "config:write:set").getCascade(
125
+ qualifiedKey,
126
+ cascadeKeyDef,
127
+ tenantId,
128
+ event.user.id,
129
+ validateDb,
130
+ ctx.secrets,
131
+ );
132
+ },
116
133
  });
117
134
  } catch (error) {
118
135
  if (isKumikoError(error)) return writeFailure(error);
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  type ConfigKeyDefinition,
3
3
  type ConfigScope,
4
+ ConfigScopes,
4
5
  type ConfigValueSource,
5
6
  defineQueryHandler,
6
7
  isEncryptedAtRest,
@@ -8,7 +9,7 @@ import {
8
9
  import * as z from "zod";
9
10
  import { requireConfigResolver, requireSystemDb } from "../feature.ts";
10
11
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.ts";
11
- import { hasConfigAccess } from "../write-helpers.ts";
12
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.ts";
12
13
 
13
14
  export const valuesQuery = defineQueryHandler({
14
15
  name: "values",
@@ -16,6 +17,7 @@ export const valuesQuery = defineQueryHandler({
16
17
  "Returns the effective value, scope and winning source for every config key the caller may read (optionally narrowed to `keys`), with encrypted and secret-backed values masked; use it to inspect the current settings.",
17
18
  schema: z.object({
18
19
  keys: z.array(z.string()).optional(),
20
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
19
21
  }),
20
22
  // Per-key read access enforced via hasConfigAccess inside the handler.
21
23
  access: {
@@ -44,7 +46,7 @@ export const valuesQuery = defineQueryHandler({
44
46
  for (const [qualifiedKey, keyDef] of allKeys) {
45
47
  if (requestedKeys !== undefined && !requestedKeys.has(qualifiedKey)) continue;
46
48
  if (!hasConfigAccess(keyDef.access.read, query.user.roles)) continue;
47
- keyDefs.set(qualifiedKey, keyDef);
49
+ keyDefs.set(qualifiedKey, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
48
50
  filteredKeys.push(qualifiedKey);
49
51
  }
50
52
 
@@ -75,8 +75,9 @@ export async function configRowHoldsValue(
75
75
  serializedValue: string,
76
76
  ): Promise<boolean> {
77
77
  if (row?.value === null || row?.value === undefined) return false;
78
- if (!keyDef?.encrypted && !keyDef?.piiEncrypted) return row.value === serializedValue;
79
- if (!keyDef) return false;
78
+ if (keyDef === undefined || (!keyDef.encrypted && !keyDef.piiEncrypted)) {
79
+ return row.value === serializedValue;
80
+ }
80
81
  try {
81
82
  return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
82
83
  } catch {
@@ -194,12 +195,7 @@ export function validateScope(
194
195
  definedScope: ConfigScope,
195
196
  key: string,
196
197
  ): KumikoError | null {
197
- const levels: Record<ConfigScope, number> = {
198
- [ConfigScopes.system]: 0,
199
- [ConfigScopes.tenant]: 1,
200
- [ConfigScopes.user]: 2,
201
- };
202
- if (levels[requestedScope] > levels[definedScope]) {
198
+ if (SCOPE_LEVEL[requestedScope] > SCOPE_LEVEL[definedScope]) {
203
199
  return new UnprocessableError("invalid_scope", {
204
200
  i18nKey: "config.errors.invalidScope",
205
201
  details: { key, definedScope, requestedScope },
@@ -208,6 +204,29 @@ export function validateScope(
208
204
  return null;
209
205
  }
210
206
 
207
+ const SCOPE_LEVEL: Record<ConfigScope, number> = {
208
+ [ConfigScopes.system]: 0,
209
+ [ConfigScopes.tenant]: 1,
210
+ [ConfigScopes.user]: 2,
211
+ };
212
+
213
+ // Reads a tenant/user key at a narrower scope, e.g. system for a system-level screen.
214
+ // Only a SystemAdmin may widen the read to the platform level; everyone else keeps the normal cascade.
215
+ export function keyDefAtScope(
216
+ keyDef: ConfigKeyDefinition,
217
+ readScope: ConfigScope | undefined,
218
+ roles: readonly string[],
219
+ ): ConfigKeyDefinition {
220
+ if (
221
+ readScope === undefined ||
222
+ !roles.includes("SystemAdmin") ||
223
+ SCOPE_LEVEL[readScope] >= SCOPE_LEVEL[keyDef.scope]
224
+ ) {
225
+ return keyDef;
226
+ }
227
+ return { ...keyDef, scope: readScope };
228
+ }
229
+
211
230
  export function resolveScopeIds(
212
231
  scope: ConfigScope,
213
232
  tenantId: TenantId,
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.389.0",
4
+ "type": "improvement",
5
+ "title": "Tests pin the erasure write purpose in forget-subject and the forget cleanup",
6
+ "detail": "A pre-KMS user whose key was already erased still ends deleted, loses its tokens and gets the erased sentinel in the update event."
7
+ },
8
+ {
9
+ "version": "0.389.0",
10
+ "type": "improvement",
11
+ "title": "The subject file purge reads candidate rows in pages",
12
+ "detail": "A user owning many records no longer loads them all into memory at once; every page is purged, not only the first."
13
+ },
14
+ {
15
+ "version": "0.388.0",
16
+ "type": "fix",
17
+ "title": "forget-subject audit event survives a failing record owner lookup",
18
+ "detail": "The lookup of the record row's owning tenant now runs inside the file purge step, so a database error there is reported as cleanupError on the subject-forgotten event instead of aborting the handler after the key was erased."
19
+ },
2
20
  {
3
21
  "version": "0.377.0",
4
22
  "type": "fix",