@cosmicdrift/kumiko-bundled-features 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 (320) hide show
  1. package/dist/agent-tools/index.d.ts +1 -1
  2. package/dist/agent-tools/index.js +1 -1
  3. package/dist/agent-tools/tool-dispatch.d.ts +12 -7
  4. package/dist/agent-tools/tool-dispatch.js +42 -31
  5. package/dist/audit/feature.js +1 -0
  6. package/dist/audit/handlers/audit-events.js +1 -1
  7. package/dist/auth-email-password/handlers/request-account-unlock.write.d.ts +1 -0
  8. package/dist/auth-email-password/handlers/request-account-unlock.write.js +1 -0
  9. package/dist/auth-email-password/handlers/request-email-verification.write.d.ts +1 -0
  10. package/dist/auth-email-password/handlers/request-email-verification.write.js +1 -0
  11. package/dist/auth-email-password/handlers/request-password-reset.write.d.ts +1 -0
  12. package/dist/auth-email-password/handlers/request-password-reset.write.js +1 -0
  13. package/dist/auth-email-password/handlers/signup-request.write.d.ts +1 -0
  14. package/dist/auth-email-password/handlers/signup-request.write.js +4 -0
  15. package/dist/auth-email-password/handlers/token-request-handler.d.ts +2 -0
  16. package/dist/auth-email-password/handlers/token-request-handler.js +5 -0
  17. package/dist/auth-email-password/magic-link-mail.d.ts +2 -0
  18. package/dist/auth-email-password/magic-link-mail.js +7 -3
  19. package/dist/auth-email-password/signup-activation.d.ts +1 -0
  20. package/dist/auth-email-password/signup-activation.js +1 -0
  21. package/dist/auth-email-password/web/auth-client.d.ts +2 -2
  22. package/dist/auth-email-password/web/auth-client.js +8 -4
  23. package/dist/auth-email-password/web/auth-redirect.d.ts +5 -1
  24. package/dist/auth-email-password/web/auth-redirect.js +20 -25
  25. package/dist/auth-email-password/web/default-auth-pages-gate.js +6 -3
  26. package/dist/auth-email-password/web/forgot-password-screen.d.ts +4 -1
  27. package/dist/auth-email-password/web/forgot-password-screen.js +7 -2
  28. package/dist/auth-email-password/web/index.d.ts +1 -0
  29. package/dist/auth-email-password/web/index.js +1 -0
  30. package/dist/auth-email-password/web/login-screen.js +11 -6
  31. package/dist/auth-email-password/web/reset-password-screen.js +7 -1
  32. package/dist/auth-email-password/web/return-path.d.ts +4 -0
  33. package/dist/auth-email-password/web/return-path.js +34 -0
  34. package/dist/auth-email-password/web/session.js +6 -1
  35. package/dist/auth-email-password/web/signup-complete-screen.js +11 -3
  36. package/dist/auth-email-password/web/signup-screen.d.ts +4 -1
  37. package/dist/auth-email-password/web/signup-screen.js +8 -3
  38. package/dist/auth-mfa/config.d.ts +1 -1
  39. package/dist/auth-mfa/config.js +3 -7
  40. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  41. package/dist/auth-mfa/feature.js +8 -0
  42. package/dist/auth-mfa/mfa-status-checker.js +5 -3
  43. package/dist/auth-mfa/web/i18n.js +5 -1
  44. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  45. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  46. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  47. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  48. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  49. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  50. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  51. package/dist/billing-foundation/checkout-core.js +24 -39
  52. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  53. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  54. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  55. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  56. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  57. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  58. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  59. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  60. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  61. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  62. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  63. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  64. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  65. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  66. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  67. package/dist/billing-foundation/events.d.ts +1 -0
  68. package/dist/billing-foundation/events.js +1 -0
  69. package/dist/billing-foundation/feature.js +3 -1
  70. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  71. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  72. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  73. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  74. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  75. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  76. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  77. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  78. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  79. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  80. package/dist/billing-foundation/i18n.js +22 -0
  81. package/dist/billing-foundation/index.d.ts +1 -1
  82. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  83. package/dist/billing-foundation/plan-catalog.js +25 -16
  84. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  85. package/dist/billing-foundation/plan-switch.js +60 -0
  86. package/dist/billing-foundation/types.d.ts +57 -1
  87. package/dist/billing-foundation/types.js +9 -0
  88. package/dist/billing-foundation/validate-options.js +17 -0
  89. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  90. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  91. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  92. package/dist/billing-foundation/web/constants.d.ts +4 -0
  93. package/dist/billing-foundation/web/constants.js +4 -0
  94. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  95. package/dist/billing-foundation/web/plan-price.js +32 -0
  96. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  97. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  98. package/dist/cap-counter/cap-reservation.js +20 -7
  99. package/dist/cap-counter/enforce-cap.js +2 -1
  100. package/dist/cap-counter/feature.js +1 -0
  101. package/dist/cap-counter/stock-cap-guard.js +7 -1
  102. package/dist/cap-overview/feature.js +1 -0
  103. package/dist/channel-email/feature.js +1 -1
  104. package/dist/config/handlers/cascade.query.d.ts +5 -0
  105. package/dist/config/handlers/cascade.query.js +4 -3
  106. package/dist/config/handlers/set.write.js +12 -2
  107. package/dist/config/handlers/values.query.d.ts +5 -0
  108. package/dist/config/handlers/values.query.js +4 -3
  109. package/dist/config/write-helpers.d.ts +1 -0
  110. package/dist/config/write-helpers.js +18 -9
  111. package/dist/crypto-shredding/handlers/forget-subject.write.js +14 -11
  112. package/dist/custom-fields/lib/parse-serialized-field.js +1 -1
  113. package/dist/delivery/channel-context.js +6 -3
  114. package/dist/delivery/db/queries/preferences.js +2 -2
  115. package/dist/delivery/delivery-service.js +22 -7
  116. package/dist/delivery/feature.js +1 -0
  117. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  118. package/dist/delivery/handlers/preferences.query.js +20 -1
  119. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  120. package/dist/delivery/index.d.ts +1 -0
  121. package/dist/delivery/index.js +1 -0
  122. package/dist/delivery/system-channel-context.d.ts +15 -0
  123. package/dist/delivery/system-channel-context.js +5 -0
  124. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  125. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  126. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  127. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  128. package/dist/delivery/unsubscribe-page.js +5 -0
  129. package/dist/delivery/unsubscribe.js +25 -7
  130. package/dist/document-ingest-foundation/append-missing-extract-event.d.ts +21 -0
  131. package/dist/document-ingest-foundation/append-missing-extract-event.js +42 -0
  132. package/dist/document-ingest-foundation/events.d.ts +11 -0
  133. package/dist/document-ingest-foundation/events.js +11 -0
  134. package/dist/document-ingest-foundation/index.d.ts +2 -1
  135. package/dist/document-ingest-foundation/index.js +2 -1
  136. package/dist/feature-toggles/feature.js +1 -0
  137. package/dist/file-foundation/index.d.ts +1 -1
  138. package/dist/file-foundation/index.js +1 -1
  139. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  140. package/dist/file-foundation/read-file-ref-content.js +6 -1
  141. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  142. package/dist/form-draft/db/queries/cleanup.js +2 -2
  143. package/dist/form-draft/decrypt-draft.js +5 -1
  144. package/dist/host-routing/dispatch.js +11 -20
  145. package/dist/host-routing/validate-options.js +3 -0
  146. package/dist/inbound-mail-foundation/feature.js +1 -0
  147. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  148. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  149. package/dist/inbound-mail-foundation/i18n.js +0 -1
  150. package/dist/inbound-mail-foundation/screens.js +0 -3
  151. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  152. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  153. package/dist/jobs/feature.d.ts +1 -0
  154. package/dist/jobs/feature.js +9 -4
  155. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  156. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  157. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  158. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  159. package/dist/jobs/job-run-logger.js +5 -4
  160. package/dist/ledger/web/index.js +1 -1
  161. package/dist/legal-pages/seed.d.ts +3 -1
  162. package/dist/legal-pages/seed.js +2 -2
  163. package/dist/mail-foundation/feature.d.ts +3 -3
  164. package/dist/mail-foundation/feature.js +3 -0
  165. package/dist/mail-transport-smtp/config-keys.js +1 -1
  166. package/dist/mail-transport-smtp/feature.js +6 -5
  167. package/dist/managed-pages/constants.d.ts +1 -0
  168. package/dist/managed-pages/constants.js +1 -0
  169. package/dist/managed-pages/feature.js +3 -1
  170. package/dist/managed-pages/index.d.ts +1 -0
  171. package/dist/managed-pages/index.js +1 -0
  172. package/dist/notes-history/handlers/add-note.write.js +1 -1
  173. package/dist/personal-access-tokens/feature.d.ts +1 -1
  174. package/dist/personal-access-tokens/feature.js +5 -0
  175. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  176. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  177. package/dist/secrets/write-gate.js +8 -2
  178. package/dist/sessions/feature.d.ts +2 -2
  179. package/dist/sessions/feature.js +5 -0
  180. package/dist/sessions/index.d.ts +1 -1
  181. package/dist/sessions/index.js +1 -1
  182. package/dist/sessions/session-callbacks.d.ts +0 -2
  183. package/dist/sessions/session-callbacks.js +1 -3
  184. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  185. package/dist/shared/decrypt-stored-pii.js +6 -1
  186. package/dist/shared/index.d.ts +1 -1
  187. package/dist/shared/index.js +1 -1
  188. package/dist/shared/mail-template-body.d.ts +6 -2
  189. package/dist/shared/mail-template-body.js +7 -4
  190. package/dist/shared/purge-files-reaching-subject.d.ts +1 -0
  191. package/dist/shared/purge-files-reaching-subject.js +19 -2
  192. package/dist/shared/run-in-sub-transaction.js +5 -5
  193. package/dist/step-dispatcher/deliver-dispatch-request.js +16 -3
  194. package/dist/step-dispatcher/feature.js +1 -0
  195. package/dist/step-dispatcher/mail-runner.d.ts +1 -0
  196. package/dist/subscription-dodo/client.d.ts +17 -0
  197. package/dist/subscription-dodo/client.js +31 -0
  198. package/dist/subscription-dodo/constants.d.ts +12 -0
  199. package/dist/subscription-dodo/constants.js +19 -0
  200. package/dist/subscription-dodo/feature.d.ts +37 -0
  201. package/dist/subscription-dodo/feature.js +144 -0
  202. package/dist/subscription-dodo/index.d.ts +2 -0
  203. package/dist/subscription-dodo/index.js +4 -0
  204. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  205. package/dist/subscription-dodo/plugin-methods.js +260 -0
  206. package/dist/subscription-dodo/runtime.d.ts +27 -0
  207. package/dist/subscription-dodo/runtime.js +89 -0
  208. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  209. package/dist/subscription-dodo/verify-webhook.js +189 -0
  210. package/dist/subscription-stripe/feature.js +1 -0
  211. package/dist/subscription-stripe/runtime.js +7 -6
  212. package/dist/tags/feature.js +1 -0
  213. package/dist/template-resolver/api.d.ts +3 -2
  214. package/dist/template-resolver/api.js +24 -2
  215. package/dist/template-resolver/compile-document-body.js +1 -0
  216. package/dist/template-resolver/document-ops.d.ts +5 -0
  217. package/dist/template-resolver/document-ops.js +18 -2
  218. package/dist/template-resolver/feature.js +4 -4
  219. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  220. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  221. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  222. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  223. package/dist/template-resolver/handlers/document-base.js +3 -2
  224. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  225. package/dist/template-resolver/handlers/layout-source.query.d.ts +4 -0
  226. package/dist/template-resolver/handlers/layout-source.query.js +26 -6
  227. package/dist/template-resolver/handlers/list.query.js +5 -6
  228. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  229. package/dist/template-resolver/handlers/overview.query.js +21 -10
  230. package/dist/template-resolver/handlers/patch-draft.write.js +1 -1
  231. package/dist/template-resolver/handlers/send-test-status.query.d.ts +2 -1
  232. package/dist/template-resolver/handlers/send-test-status.query.js +18 -15
  233. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  234. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  235. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  236. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  237. package/dist/template-resolver/handlers/usage.query.js +26 -16
  238. package/dist/template-resolver/handlers/validate.query.js +7 -2
  239. package/dist/template-resolver/seeding.d.ts +1 -0
  240. package/dist/template-resolver/seeding.js +31 -12
  241. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  242. package/dist/template-resolver/store-template-resource.js +65 -10
  243. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  244. package/dist/template-resolver/template-delivery-policy.js +0 -6
  245. package/dist/template-resolver/template-meta.js +7 -4
  246. package/dist/template-resolver/template-preview.js +4 -3
  247. package/dist/template-resolver/web/constants.d.ts +2 -2
  248. package/dist/template-resolver/web/constants.js +3 -3
  249. package/dist/template-resolver/web/i18n.js +3 -0
  250. package/dist/template-resolver/web/index.d.ts +1 -0
  251. package/dist/template-resolver/web/index.js +1 -0
  252. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  253. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  254. package/dist/template-resolver/web/qualified-names.d.ts +1 -0
  255. package/dist/template-resolver/web/qualified-names.js +1 -0
  256. package/dist/tenant/feature.js +1 -0
  257. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  258. package/dist/tenant/handlers/member-directory.query.js +10 -3
  259. package/dist/tenant/handlers/team-list.query.js +3 -3
  260. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  261. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  262. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  263. package/dist/tenant-lifecycle/constants.js +4 -0
  264. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  265. package/dist/tenant-lifecycle/stages.js +22 -1
  266. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  267. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  268. package/dist/tier-engine/web/constants.js +2 -2
  269. package/dist/user/feature.js +1 -0
  270. package/dist/user-data-rights/feature.js +1 -0
  271. package/dist/user-data-rights/index.d.ts +1 -1
  272. package/dist/user-data-rights/index.js +1 -1
  273. package/dist/user-data-rights/run-export-jobs.js +3 -4
  274. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  275. package/dist/user-data-rights/web/constants.d.ts +6 -0
  276. package/dist/user-data-rights/web/constants.js +6 -0
  277. package/dist/user-data-rights/web/index.d.ts +1 -0
  278. package/dist/user-data-rights/web/index.js +1 -0
  279. package/dist/waitlist/feature.js +1 -0
  280. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  281. package/dist/waitlist/handlers/submit.write.js +14 -10
  282. package/dist/waitlist/import.d.ts +3 -1
  283. package/dist/waitlist/import.js +26 -2
  284. package/dist/waitlist/legacy-migrate.js +18 -7
  285. package/dist/waitlist/link-on-signup.js +18 -6
  286. package/dist/waitlist/options.d.ts +5 -1
  287. package/dist/workflow-runner/handlers/resume-run.write.js +1 -1
  288. package/package.json +19 -9
  289. package/src/agent-tools/changes.json +12 -0
  290. package/src/audit/changes.json +12 -0
  291. package/src/auth-email-password/changes.json +12 -0
  292. package/src/auth-mfa/changes.json +12 -0
  293. package/src/billing-foundation/changes.json +55 -0
  294. package/src/cap-counter/changes.json +30 -0
  295. package/src/channel-email/changes.json +6 -0
  296. package/src/config/changes.json +18 -0
  297. package/src/crypto-shredding/changes.json +18 -0
  298. package/src/delivery/changes.json +42 -0
  299. package/src/document-ingest-foundation/changes.json +6 -0
  300. package/src/form-draft/changes.json +6 -0
  301. package/src/host-routing/changes.json +12 -0
  302. package/src/inbound-mail-foundation/changes.json +18 -0
  303. package/src/jobs/changes.json +13 -0
  304. package/src/mail-foundation/changes.json +6 -0
  305. package/src/mail-transport-smtp/changes.json +6 -0
  306. package/src/managed-pages/changes.json +6 -0
  307. package/src/renderer-pdf/changes.json +6 -0
  308. package/src/secrets/changes.json +6 -0
  309. package/src/sessions/changes.json +7 -0
  310. package/src/step-dispatcher/changes.json +12 -0
  311. package/src/subscription-dodo/changes.json +14 -0
  312. package/src/subscription-stripe/changes.json +12 -0
  313. package/src/template-resolver/changes.json +84 -0
  314. package/src/tenant/changes.json +6 -0
  315. package/src/tenant-lifecycle/changes.json +12 -0
  316. package/src/tier-engine/changes.json +6 -0
  317. package/src/user-data-rights/changes.json +12 -0
  318. package/src/waitlist/changes.json +36 -0
  319. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  320. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -0,0 +1,66 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useLocale, useMutation, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
3
+ import { formatMoney } from "@cosmicdrift/kumiko-renderer/web";
4
+ import { useState } from "react";
5
+ import { CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
6
+ import { SubscriptionFoundationHandlers, SubscriptionFoundationQueries } from "./constants.js";
7
+ import { resolveConsentLocale } from "./consumer-protection/consent-locale.js";
8
+ import { intervalKey } from "./plan-price.js";
9
+ // kumiko-lint-ignore no-custom-primitives Uses usePrimitives().Modal internally, name is a billing domain action, not a primitive reimplementation
10
+ export function PlanSwitchConfirmDialog({ open, onOpenChange, tier, fromPlanName, toPlanName, consumerProtection, onApplied, onConsentTextOutdated, }) {
11
+ const t = useTranslation();
12
+ const locale = useLocale().locale();
13
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
14
+ const previewQuery = useQuery(SubscriptionFoundationQueries.planSwitchPreview, { tier });
15
+ const switchMutation = useMutation(SubscriptionFoundationHandlers.switchPlan);
16
+ const [earlyPerformance, setEarlyPerformance] = useState(false);
17
+ const [withdrawalLoss, setWithdrawalLoss] = useState(false);
18
+ const [submitting, setSubmitting] = useState(false);
19
+ const [errorText, setErrorText] = useState(null);
20
+ const consentLocale = resolveConsentLocale(locale);
21
+ const texts = consumerProtection?.consentTexts[consentLocale];
22
+ const legalLinks = consumerProtection?.legalLinks[consentLocale];
23
+ const consentGiven = consumerProtection === undefined || (earlyPerformance && withdrawalLoss);
24
+ async function confirm() {
25
+ setSubmitting(true);
26
+ setErrorText(null);
27
+ const consent = texts === undefined
28
+ ? undefined
29
+ : {
30
+ earlyPerformanceRequested: true,
31
+ withdrawalLossAcknowledged: true,
32
+ consentTextVersion: texts.consentTextVersion,
33
+ locale: consentLocale,
34
+ };
35
+ const result = await switchMutation.mutate({ tier, ...(consent && { consent }) });
36
+ if (result.isSuccess) {
37
+ onApplied();
38
+ return;
39
+ }
40
+ setSubmitting(false);
41
+ setErrorText(t(result.error.i18nKey, result.error.i18nParams));
42
+ if (result.error.code === CONSENT_TEXT_OUTDATED_CODE) {
43
+ setEarlyPerformance(false);
44
+ setWithdrawalLoss(false);
45
+ onConsentTextOutdated();
46
+ }
47
+ }
48
+ const reply = previewQuery.data;
49
+ const money = (amount, currency) => formatMoney(amount, currency, locale);
50
+ const recurringKey = reply === null ? undefined : intervalKey(reply.target);
51
+ const recurringPrice = reply === null
52
+ ? ""
53
+ : `${money(reply.target.unitAmount, reply.target.currency)}${recurringKey === undefined
54
+ ? ""
55
+ : ` ${t(recurringKey, { count: new Intl.NumberFormat(locale).format(reply.target.intervalCount ?? 1) })}`}`;
56
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.planSwitch.title"), testId: "plan-switch-confirm-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Text, { children: t("billing-foundation.planSwitch.summary", { from: fromPlanName, to: toPlanName }) }), previewQuery.error !== null && (_jsx(Banner, { variant: "error", testId: "plan-switch-preview-error", children: t(previewQuery.error.i18nKey, previewQuery.error.i18nParams) })), previewQuery.loading && reply === null && (_jsx(Banner, { variant: "loading", testId: "plan-switch-preview-loading", children: t("kumiko.widget.loading") })), reply !== null && (_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "plan-switch-summary", children: [_jsxs(Text, { children: [t("billing-foundation.planSwitch.dueNow", {
57
+ amount: money(reply.preview.amountDueNow, reply.preview.currency),
58
+ }), reply.preview.tax !== null &&
59
+ ` (${t("billing-foundation.planSwitch.includesTax", {
60
+ tax: money(reply.preview.tax, reply.preview.currency),
61
+ })})`] }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.credit", {
62
+ amount: money(reply.preview.credit, reply.preview.currency),
63
+ }) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.recurring", {
64
+ amount: recurringPrice,
65
+ }) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.effective") }), _jsx(Text, { variant: "muted", children: t("billing-foundation.planSwitch.pendingNote") })] })), legalLinks !== undefined && (_jsxs("div", { className: "flex flex-wrap gap-4", children: [_jsx(Link, { href: legalLinks.terms, target: "_blank", children: t("billing-foundation.consent.link.terms") }), _jsx(Link, { href: legalLinks.withdrawal, target: "_blank", children: t("billing-foundation.consent.link.withdrawal") }), _jsx(Link, { href: legalLinks.privacy, target: "_blank", children: t("billing-foundation.consent.link.privacy") })] })), texts !== undefined && (_jsxs(_Fragment, { children: [_jsx(Field, { id: "plan-switch-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "plan-switch-early-performance", name: "plan-switch-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "plan-switch-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "plan-switch-withdrawal-loss", name: "plan-switch-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) })] })), errorText !== null && (_jsx(Banner, { variant: "error", testId: "plan-switch-confirm-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "plan-switch-confirm-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !consentGiven || reply === null || submitting, loading: submitting, onClick: confirm, testId: "plan-switch-confirm-order", children: t("billing-foundation.planSwitch.order") })] })] }) }));
66
+ }
@@ -186,6 +186,16 @@ function bookCalendarDelta(txDb, user, period, delta) {
186
186
  return Promise.resolve({ isSuccess: true, data: {} });
187
187
  return applyCapDeltaOn(txDb, user, { ...period, amount: Math.abs(delta) }, delta > 0 ? "add" : "subtract");
188
188
  }
189
+ // Asked after a missed or lost claim. A released tombstone committed since the first read does not
190
+ // count: the caller's own tombstone insert then conflicts with it, and the retry claims that row.
191
+ async function hasSettledTombstone(txDb, user, id) {
192
+ const settled = await txDb.fetchOne(capReservationsTable, {
193
+ id,
194
+ tenantId: user.tenantId,
195
+ settledAt: { ne: null },
196
+ });
197
+ return settled !== undefined;
198
+ }
189
199
  async function settleInTransaction(txDb, user, id, options) {
190
200
  const ownRow = { id, tenantId: user.tenantId };
191
201
  const open = await txDb.fetchOne(capReservationsTable, {
@@ -205,12 +215,11 @@ async function settleInTransaction(txDb, user, id, options) {
205
215
  await txDb.updateMany(capReservationsTable, { settledAt: now, expiresAt: now.add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }) }, ownRow);
206
216
  return booked;
207
217
  }
208
- // The claim lost to a concurrent settle or release: a tombstone means the amount is booked.
209
- if (await txDb.fetchOne(capReservationsTable, ownRow)) {
218
+ if (await hasSettledTombstone(txDb, user, id))
210
219
  return NOTHING_TO_RELEASE;
211
- }
212
220
  // Row swept or released: its amount was given back, so the full actual amount is booked and a
213
- // tombstone makes a repeated settle a no-op. A parallel settle inserting first fails this insert.
221
+ // tombstone makes a repeated settle a no-op. A parallel settle or consume inserting first fails
222
+ // this insert.
214
223
  // Booking first: a lost counter race returns a result instead of throwing, so the transaction
215
224
  // commits and a tombstone written before it would turn the retry into a no-op.
216
225
  const booked = await bookCalendarDelta(txDb, user, options, options.actualAmount);
@@ -269,7 +278,11 @@ async function consumeClaimedRow(txDb, user, claimed, options) {
269
278
  const booked = await bookCalendarDelta(txDb, user, options, options.units);
270
279
  if (!booked.isSuccess)
271
280
  return booked;
272
- await txDb.updateMany(capReservationsTable, { consumedAmount: claimed.consumedAmount + options.units }, ownRow);
281
+ await txDb.updateMany(capReservationsTable, {
282
+ consumedAmount: claimed.consumedAmount + options.units,
283
+ // Keep the tombstone alive past its last consume, else a later consume would start a fresh one and double count.
284
+ expiresAt: Temporal.Now.instant().add({ minutes: CAP_SETTLED_TOMBSTONE_TTL_MINUTES }),
285
+ }, ownRow);
273
286
  return booked;
274
287
  }
275
288
  if (options.units > claimed.amount) {
@@ -282,7 +295,7 @@ async function consumeClaimedRow(txDb, user, claimed, options) {
282
295
  return NOTHING_TO_RELEASE;
283
296
  }
284
297
  // Row already expired away: the units are booked directly and a released tombstone remembers them
285
- // so a later settle subtracts them. A parallel consume inserting first fails this insert.
298
+ // so a later settle subtracts them. A parallel consume or settle inserting first fails this insert.
286
299
  async function consumeWithoutRow(txDb, user, id, options) {
287
300
  // Booking first, for the same reason as settle's missing-row path.
288
301
  const booked = await bookCalendarDelta(txDb, user, options, options.units);
@@ -316,7 +329,7 @@ async function consumeInTransaction(txDb, user, id, options) {
316
329
  const claimed = open && (await claimReservationRow(txDb, user, open));
317
330
  if (claimed)
318
331
  return consumeClaimedRow(txDb, user, claimed, options);
319
- if (await txDb.fetchOne(capReservationsTable, ownRow)) {
332
+ if (await hasSettledTombstone(txDb, user, id)) {
320
333
  throw new CapReservationSettledError(options.capName);
321
334
  }
322
335
  return consumeWithoutRow(txDb, user, id, options);
@@ -16,8 +16,9 @@ export const CAP_TOLERANCES = {
16
16
  };
17
17
  export function assertBelowHardCap(valueSeenByLastUnit, cap) {
18
18
  const tolerance = CAP_TOLERANCES[cap.profile];
19
+ // Validated on every call so a misconfigured period fails deterministically, not only at the limit.
20
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
19
21
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
20
- const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
21
22
  throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
22
23
  ...(resetAt !== undefined && { resetAt }),
23
24
  ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
@@ -54,6 +54,7 @@ import { CAP_COUNTER_I18N } from "./i18n.js";
54
54
  import { capReservationsTableMeta } from "./tables.js";
55
55
  export function createCapCounterFeature() {
56
56
  return defineFeature(CAP_COUNTER_FEATURE, (r) => {
57
+ r.adoptsBundledLists();
57
58
  r.describe("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.");
58
59
  r.uiHints({
59
60
  displayLabel: "Cap Counter · Usage Limits",
@@ -7,6 +7,12 @@ import { enforceStockCap } from "./enforce-cap.js";
7
7
  // pg_advisory_xact_lock namespace (int4): 'scap' as ASCII, disjoint from the
8
8
  // framework's other fixed advisory-lock keys.
9
9
  const STOCK_CAP_LOCK_NAMESPACE = 0x73636170;
10
+ // Lock on the counted set, not on `code`: two handlers capping the same rows under
11
+ // different codes must still serialize against each other.
12
+ function countedSetKey(spec) {
13
+ const where = Object.entries(spec.where ?? {}).sort(([a], [b]) => a.localeCompare(b));
14
+ return `${spec.table.tableName}:${JSON.stringify(where)}`;
15
+ }
10
16
  export function createStockCapGuard(resolveTierCaps) {
11
17
  async function checkStockCap(db, spec, context = {}) {
12
18
  // Lock-free on its own: a direct call outside withStockCap is not atomic.
@@ -31,7 +37,7 @@ export function createStockCapGuard(resolveTierCaps) {
31
37
  // ctx.db is the handler's transaction, so the xact lock releases with its commit or
32
38
  // rollback. A handler wrapped in several caps must list them in the same order
33
39
  // everywhere; Postgres aborts one side of an inverse order with a deadlock error.
34
- await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, spec.code);
40
+ await ctx.db.advisoryXactLock(STOCK_CAP_LOCK_NAMESPACE, countedSetKey(spec));
35
41
  const failure = await checkStockCap(ctx.db, spec, { config: ctx.config });
36
42
  return failure ?? handler.handler(event, ctx);
37
43
  },
@@ -36,6 +36,7 @@ export function createCapOverviewFeature(opts) {
36
36
  }
37
37
  }
38
38
  return defineFeature(CAP_OVERVIEW_FEATURE, (r) => {
39
+ r.adoptsBundledLists();
39
40
  r.describe("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.");
40
41
  r.uiHints({
41
42
  displayLabel: "Cap Overview · Tier & Usage Visibility",
@@ -6,7 +6,7 @@ import { smtpEnvSchema } from "./env.js";
6
6
  export function createChannelEmailFeature(options) {
7
7
  const channel = createEmailChannel(options);
8
8
  const feature = defineFeature("channel-email", (r) => {
9
- r.describe("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.");
9
+ r.describe("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.");
10
10
  r.uiHints({
11
11
  displayLabel: "Email Channel",
12
12
  category: "notifications",
@@ -2,4 +2,9 @@ import { type ConfigCascade } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  export declare const cascadeQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"cascade", z.ZodObject<{
4
4
  keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
5
+ scope: z.ZodOptional<z.ZodEnum<{
6
+ system: "system";
7
+ tenant: "tenant";
8
+ user: "user";
9
+ }>>;
5
10
  }, z.core.$strip>, Record<string, ConfigCascade>, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -1,13 +1,14 @@
1
- import { defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { ConfigScopes, defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { requireConfigResolver, requireSystemDb } from "../feature.js";
4
4
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.js";
5
- import { hasConfigAccess } from "../write-helpers.js";
5
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.js";
6
6
  export const cascadeQuery = defineQueryHandler({
7
7
  name: "cascade",
8
8
  description: "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.",
9
9
  schema: z.object({
10
10
  keys: z.array(z.string()).optional(),
11
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
11
12
  }),
12
13
  access: {
13
14
  openToAll: {
@@ -34,7 +35,7 @@ export const cascadeQuery = defineQueryHandler({
34
35
  continue;
35
36
  if (!hasConfigAccess(keyDef.access.read, query.user.roles))
36
37
  continue;
37
- keyDefs.set(key, keyDef);
38
+ keyDefs.set(key, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
38
39
  filteredKeys.push(key);
39
40
  }
40
41
  const cascades = await resolver.getCascadeBatch(filteredKeys, keyDefs, query.user.tenantId, query.user.id, db, ctx.secrets);
@@ -4,7 +4,7 @@ import { createEventStoreExecutor, scopeTenantDbToTenant } from "@cosmicdrift/ku
4
4
  import { ConfigScopes, defineWriteHandler, isEncryptedAtRest, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, isKumikoError, UnprocessableError, writeFailure, } from "@cosmicdrift/kumiko-framework/errors";
6
6
  import * as z from "zod";
7
- import { requireConfigEncryption, requireSystemDb } from "../feature.js";
7
+ import { requireConfigEncryption, requireConfigResolver, requireSystemDb } from "../feature.js";
8
8
  import { MASKED } from "../read-redaction.js";
9
9
  import { configValueEntity, configValuesTable } from "../table.js";
10
10
  import { findConfigRow, prepareConfigWrite, resolvePiiSubject, validateBounds, validateExtensionPlugin, validatePattern, validateScope, validateType, } from "../write-helpers.js";
@@ -69,13 +69,23 @@ export const setWrite = defineWriteHandler({
69
69
  if (patternError)
70
70
  return writeFailure(patternError);
71
71
  if (keyDef.validate) {
72
+ const validateDb = scopeTenantDbToTenant(db, tenantId);
72
73
  try {
73
74
  await keyDef.validate(event.payload.value, {
74
75
  key: event.payload.key,
75
76
  scope,
76
77
  tenantId,
77
78
  userId,
78
- db: scopeTenantDbToTenant(db, tenantId),
79
+ db: validateDb,
80
+ readCascade: (qualifiedKey) => {
81
+ const cascadeKeyDef = ctx.registry.getConfigKey(qualifiedKey);
82
+ if (!cascadeKeyDef) {
83
+ throw new InternalError({
84
+ message: `[config:write:set] readCascade: unknown config key "${qualifiedKey}"`,
85
+ });
86
+ }
87
+ return requireConfigResolver(ctx, "config:write:set").getCascade(qualifiedKey, cascadeKeyDef, tenantId, event.user.id, validateDb, ctx.secrets);
88
+ },
79
89
  });
80
90
  }
81
91
  catch (error) {
@@ -2,6 +2,11 @@ import { type ConfigScope, type ConfigValueSource } from "@cosmicdrift/kumiko-fr
2
2
  import * as z from "zod";
3
3
  export declare const valuesQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"values", z.ZodObject<{
4
4
  keys: z.ZodOptional<z.ZodArray<z.ZodString>>;
5
+ scope: z.ZodOptional<z.ZodEnum<{
6
+ system: "system";
7
+ tenant: "tenant";
8
+ user: "user";
9
+ }>>;
5
10
  }, z.core.$strip>, Record<string, {
6
11
  value: string | number | boolean | undefined;
7
12
  scope: ConfigScope;
@@ -1,13 +1,14 @@
1
- import { defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
1
+ import { ConfigScopes, defineQueryHandler, isEncryptedAtRest, } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
3
  import { requireConfigResolver, requireSystemDb } from "../feature.js";
4
4
  import { MASKED, redactInheritedCascade, shouldRedactInherited } from "../read-redaction.js";
5
- import { hasConfigAccess } from "../write-helpers.js";
5
+ import { hasConfigAccess, keyDefAtScope } from "../write-helpers.js";
6
6
  export const valuesQuery = defineQueryHandler({
7
7
  name: "values",
8
8
  description: "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.",
9
9
  schema: z.object({
10
10
  keys: z.array(z.string()).optional(),
11
+ scope: z.enum([ConfigScopes.system, ConfigScopes.tenant, ConfigScopes.user]).optional(),
11
12
  }),
12
13
  // Per-key read access enforced via hasConfigAccess inside the handler.
13
14
  access: {
@@ -34,7 +35,7 @@ export const valuesQuery = defineQueryHandler({
34
35
  continue;
35
36
  if (!hasConfigAccess(keyDef.access.read, query.user.roles))
36
37
  continue;
37
- keyDefs.set(qualifiedKey, keyDef);
38
+ keyDefs.set(qualifiedKey, keyDefAtScope(keyDef, query.payload.scope, query.user.roles));
38
39
  filteredKeys.push(qualifiedKey);
39
40
  }
40
41
  // Resolve through the full cascade (rows → app-override → computed →
@@ -35,6 +35,7 @@ export declare function checkWriteAccess(keyDef: ConfigKeyDefinition, userRoles:
35
35
  */
36
36
  export declare function checkScopeWriteAccess(scope: ConfigScope, userRoles: readonly string[]): KumikoError | null;
37
37
  export declare function validateScope(requestedScope: ConfigScope, definedScope: ConfigScope, key: string): KumikoError | null;
38
+ export declare function keyDefAtScope(keyDef: ConfigKeyDefinition, readScope: ConfigScope | undefined, roles: readonly string[]): ConfigKeyDefinition;
38
39
  export declare function resolveScopeIds(scope: ConfigScope, tenantId: TenantId, userId: string): {
39
40
  tenantId: TenantId;
40
41
  userId: string | null;
@@ -33,10 +33,9 @@ export async function findConfigRow(db, key, tenantId, userId) {
33
33
  export async function configRowHoldsValue(row, keyDef, cipher, qualifiedKey, serializedValue) {
34
34
  if (row?.value === null || row?.value === undefined)
35
35
  return false;
36
- if (!keyDef?.encrypted && !keyDef?.piiEncrypted)
36
+ if (keyDef === undefined || (!keyDef.encrypted && !keyDef.piiEncrypted)) {
37
37
  return row.value === serializedValue;
38
- if (!keyDef)
39
- return false;
38
+ }
40
39
  try {
41
40
  return (await decryptStoredValue(row.value, keyDef, cipher, qualifiedKey)) === serializedValue;
42
41
  }
@@ -112,12 +111,7 @@ export function checkScopeWriteAccess(scope, userRoles) {
112
111
  });
113
112
  }
114
113
  export function validateScope(requestedScope, definedScope, key) {
115
- const levels = {
116
- [ConfigScopes.system]: 0,
117
- [ConfigScopes.tenant]: 1,
118
- [ConfigScopes.user]: 2,
119
- };
120
- if (levels[requestedScope] > levels[definedScope]) {
114
+ if (SCOPE_LEVEL[requestedScope] > SCOPE_LEVEL[definedScope]) {
121
115
  return new UnprocessableError("invalid_scope", {
122
116
  i18nKey: "config.errors.invalidScope",
123
117
  details: { key, definedScope, requestedScope },
@@ -125,6 +119,21 @@ export function validateScope(requestedScope, definedScope, key) {
125
119
  }
126
120
  return null;
127
121
  }
122
+ const SCOPE_LEVEL = {
123
+ [ConfigScopes.system]: 0,
124
+ [ConfigScopes.tenant]: 1,
125
+ [ConfigScopes.user]: 2,
126
+ };
127
+ // Reads a tenant/user key at a narrower scope, e.g. system for a system-level screen.
128
+ // Only a SystemAdmin may widen the read to the platform level; everyone else keeps the normal cascade.
129
+ export function keyDefAtScope(keyDef, readScope, roles) {
130
+ if (readScope === undefined ||
131
+ !roles.includes("SystemAdmin") ||
132
+ SCOPE_LEVEL[readScope] >= SCOPE_LEVEL[keyDef.scope]) {
133
+ return keyDef;
134
+ }
135
+ return { ...keyDef, scope: readScope };
136
+ }
128
137
  export function resolveScopeIds(scope, tenantId, userId) {
129
138
  switch (scope) {
130
139
  case ConfigScopes.system:
@@ -224,7 +224,6 @@ function sweepErrorLabel(err) {
224
224
  // Each step runs in its own savepoint: a database error must not abort the handler
225
225
  // transaction, and one failing step must not skip or roll back the others.
226
226
  async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunner, audit, rawSubject) {
227
- const recordTenantId = await owningTenantOfRecordSubject(crossTenantRunner, ctx.registry.features, rawSubject);
228
227
  const failureLabels = [];
229
228
  const runStep = async (step) => {
230
229
  try {
@@ -254,15 +253,19 @@ async function sweepDerivedSubjectData(ctx, subject, subjectKey, crossTenantRunn
254
253
  }
255
254
  });
256
255
  // File columns hold only the fileRef id, so the key erase never reaches the bytes.
257
- await runStep((crossTenantSubjectRunner) => purgeFilesReachingSubject({
258
- registry: ctx.registry,
259
- db: crossTenantSubjectRunner,
260
- subject,
261
- subjectKey,
262
- ...(recordTenantId !== undefined && { recordTenantId }),
263
- resolveDeleteBytes: resolveFileBytesDeleter(ctx),
264
- missingStorage: { mode: "fail" },
265
- }));
256
+ await runStep(async (crossTenantSubjectRunner) => {
257
+ // Inside the step: a failing owner lookup must land in the audit label, not abort the handler.
258
+ const recordTenantId = await owningTenantOfRecordSubject(crossTenantSubjectRunner, ctx.registry.features, rawSubject);
259
+ await purgeFilesReachingSubject({
260
+ registry: ctx.registry,
261
+ db: crossTenantSubjectRunner,
262
+ subject,
263
+ subjectKey,
264
+ ...(recordTenantId !== undefined && { recordTenantId }),
265
+ resolveDeleteBytes: resolveFileBytesDeleter(ctx),
266
+ missingStorage: { mode: "fail" },
267
+ });
268
+ });
266
269
  return failureLabels[0];
267
270
  }
268
271
  // Email subscribers and other non-user entities use user-style subject keys
@@ -396,7 +399,7 @@ export const forgetSubjectWrite = defineWriteHandler({
396
399
  // - user.updated as lifecycle event (updateUserLifecycle), so a
397
400
  // read_users rebuild doesn't wipe the flip
398
401
  // - sessions don't need active revocation — session-callbacks
399
- // re-validate user.status on every request (isPrincipalBlocked
402
+ // re-validate the user row on every request (isPrincipalRowBlocked
400
403
  // blocks Deleted).
401
404
  // Both idempotent (status set / revokedAt IS NULL filter), retry after
402
405
  // crash recovery is safe. User-feature guard: without the user feature
@@ -1,7 +1,7 @@
1
1
  // Centralised parser for `fieldDefinition.serializedField`.
2
2
  //
3
3
  // The column is stored as `text` in B1 (entity.ts) — what the db row hands
4
- // back depends on the driver: postgres-js returns text as a `string`, but
4
+ // back depends on the driver: text normally arrives as a `string`, but
5
5
  // jsonb-tolerant drivers and middleware can deliver an already-parsed
6
6
  // object. This helper normalises both, validates the shape, and centralises
7
7
  // the single boundary cast so callers don't sprinkle `as { … }`-narrowings
@@ -1,7 +1,7 @@
1
1
  import { createTenantDb } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { createFallbackLogger } from "@cosmicdrift/kumiko-framework/logging";
4
- import { readFileRefContent } from "../file-foundation/index.js";
4
+ import { FileStorageUnavailableError, readFileRefContent, } from "../file-foundation/index.js";
5
5
  import { collectRendererPlugins, createRendererFoundationApi, RendererError, } from "../renderer-foundation/index.js";
6
6
  import { FALLBACK_LOCALE } from "../template-resolver/index.js";
7
7
  export function createTemplateMailRenderer(db, registry, tenantId, config) {
@@ -44,15 +44,18 @@ function createPdfRenderer(db, registry, tenantId, config, filesForTenant) {
44
44
  return { content: response.pdfBytes, contentType: PDF_CONTENT_TYPE };
45
45
  };
46
46
  }
47
- // A decorative inline image (logo) that cannot be read must not fail the whole mail.
47
+ // A decorative inline image (logo) of a tenant without file storage must not fail the whole mail;
48
+ // transient read errors rethrow so the send job retries.
48
49
  async function readInlineImageOrNull(fileRefId, read) {
49
50
  try {
50
51
  return await read();
51
52
  }
52
53
  catch (err) {
54
+ if (!(err instanceof FileStorageUnavailableError))
55
+ throw err;
53
56
  createFallbackLogger("delivery").warn("inline image could not be read, sending without it", {
54
57
  fileRefId,
55
- errorName: err instanceof Error ? err.name : typeof err,
58
+ errorName: err.name,
56
59
  });
57
60
  return null;
58
61
  }
@@ -1,4 +1,4 @@
1
- import { unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
1
+ import { toPostgresArrayLiteral, unsafeReadRetrying } from "@cosmicdrift/kumiko-framework/db";
2
2
  // @wrapper-known semantic-alias
3
3
  export async function selectNotificationPreferences(db, tenantId, userId, notificationTypeNames, channelName) {
4
4
  return unsafeReadRetrying(db, `SELECT notification_type AS "notificationType", channel, enabled
@@ -9,5 +9,5 @@ export async function selectNotificationPreferences(db, tenantId, userId, notifi
9
9
  (notification_type = ANY($3::text[]) AND channel = $4)
10
10
  OR (notification_type = '*' AND channel = $4)
11
11
  OR (notification_type = ANY($3::text[]) AND channel = '*')
12
- )`, [tenantId, userId, notificationTypeNames, channelName]);
12
+ )`, [tenantId, userId, toPostgresArrayLiteral(notificationTypeNames), channelName]);
13
13
  }
@@ -266,9 +266,27 @@ export function createDeliveryService(options) {
266
266
  });
267
267
  }
268
268
  }
269
+ // The app's tenantOrigin callback typically hits the DB: once per run and tenant, not per recipient.
270
+ const unsubscribeOriginsByRun = new WeakMap();
271
+ function unsubscribeOriginOnce(run, links, channelCtx, notificationType) {
272
+ let originsByTenant = unsubscribeOriginsByRun.get(run);
273
+ if (originsByTenant === undefined) {
274
+ originsByTenant = new Map();
275
+ unsubscribeOriginsByRun.set(run, originsByTenant);
276
+ }
277
+ let origin = originsByTenant.get(channelCtx.tenantId);
278
+ if (origin === undefined) {
279
+ origin = unsubscribeLinkOrigin(links, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
280
+ tenantId: channelCtx.tenantId,
281
+ notificationType,
282
+ }));
283
+ originsByTenant.set(channelCtx.tenantId, origin);
284
+ }
285
+ return origin;
286
+ }
269
287
  // A caller-supplied unsubscribeUrl wins; the recipient comes from the server-side notify
270
288
  // context, never from the notification data.
271
- async function withUnsubscribeUrl(message, channelName, recipient, channelCtx) {
289
+ async function withUnsubscribeUrl(run, message, channelName, recipient, channelCtx) {
272
290
  if (!unsubscribeLinks || channelName !== EMAIL_CHANNEL_NAME)
273
291
  return message;
274
292
  if (registry.getAllNotifications().get(message.notificationType)?.unsubscribe !== true) {
@@ -277,10 +295,7 @@ export function createDeliveryService(options) {
277
295
  const supplied = message.data?.["unsubscribeUrl"];
278
296
  if (typeof supplied === "string" && supplied !== "")
279
297
  return message;
280
- const origin = await unsubscribeLinkOrigin(unsubscribeLinks, channelCtx, (reason) => logError.warn(`unsubscribe link falls back to the app origin: ${reason}`, {
281
- tenantId: channelCtx.tenantId,
282
- notificationType: message.notificationType,
283
- }));
298
+ const origin = await unsubscribeOriginOnce(run, unsubscribeLinks, channelCtx, message.notificationType);
284
299
  const unsubscribeUrl = await buildUnsubscribeUrl(unsubscribeLinks, origin, recipient, {
285
300
  tenantId: channelCtx.tenantId,
286
301
  notificationType: message.notificationType,
@@ -476,7 +491,7 @@ export function createDeliveryService(options) {
476
491
  run,
477
492
  channel,
478
493
  address,
479
- message: await withUnsubscribeUrl(message, channel.name, { kind: "user", userId }, channelCtx),
494
+ message: await withUnsubscribeUrl(run, message, channel.name, { kind: "user", userId }, channelCtx),
480
495
  channelCtx,
481
496
  tenantId,
482
497
  recipientId: userId,
@@ -549,7 +564,7 @@ export function createDeliveryService(options) {
549
564
  run,
550
565
  channel,
551
566
  address,
552
- message: await withUnsubscribeUrl(message, channel.name, { kind: "address", address }, channelCtx),
567
+ message: await withUnsubscribeUrl(run, message, channel.name, { kind: "address", address }, channelCtx),
553
568
  channelCtx,
554
569
  tenantId,
555
570
  recipientId,
@@ -21,6 +21,7 @@ export function createDeliveryFeature(options) {
21
21
  const resolvedAccess = options?.access ?? access.admin;
22
22
  const attemptLogRetentionDays = resolveAttemptLogRetentionDays(options?.attemptLogRetentionDays);
23
23
  return defineFeature("delivery", (r) => {
24
+ r.adoptsBundledLists();
24
25
  r.describe("The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: \"deferred\" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: \"dispatched\", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting \u2014 add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.");
25
26
  r.uiHints({
26
27
  displayLabel: "Notifications \u00b7 Dispatch Core",
@@ -1,4 +1,4 @@
1
1
  import * as z from "zod";
2
2
  export declare const preferencesQuery: import("@cosmicdrift/kumiko-types/define-handler").QueryHandlerDefinition<"preferences", z.ZodObject<{}, z.core.$strip>, {
3
- rows: readonly any[];
3
+ rows: any[];
4
4
  }, import("@cosmicdrift/kumiko-types/event-type-map").KumikoEventTypeMap>;
@@ -2,6 +2,7 @@ import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
3
3
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
4
4
  import * as z from "zod";
5
+ import { currentNotificationType } from "../notification-type-aliases.js";
5
6
  import { notificationPreferencesTable } from "../tables.js";
6
7
  export const preferencesQuery = defineQueryHandler({
7
8
  name: "preferences",
@@ -31,6 +32,24 @@ export const preferencesQuery = defineQueryHandler({
31
32
  tenantId: query.user.tenantId,
32
33
  userId: query.user.id,
33
34
  });
34
- return { rows };
35
+ // Rows stored under a former notification name are reported under the current
36
+ // one, collapsed with the same precedence as delivery: an exact row wins,
37
+ // otherwise any disabled former-name row disables.
38
+ const collapsed = new Map();
39
+ for (const row of rows) {
40
+ const notificationType = currentNotificationType(ctx.registry, row.notificationType);
41
+ const key = `${notificationType}\u0000${row.channel}`;
42
+ const isExact = row.notificationType === notificationType;
43
+ const existing = collapsed.get(key);
44
+ if (existing?.isExact)
45
+ continue;
46
+ const formerNameRowDisablesAlready = existing !== undefined &&
47
+ !isExact &&
48
+ (existing.row.enabled === false || row.enabled !== false);
49
+ if (formerNameRowDisablesAlready)
50
+ continue;
51
+ collapsed.set(key, { row: { ...row, notificationType }, isExact });
52
+ }
53
+ return { rows: [...collapsed.values()].map((entry) => entry.row) };
35
54
  },
36
55
  });
@@ -23,7 +23,7 @@ export const unsubscribePageChromeQuery = defineQueryHandler({
23
23
  // Only reachable via a system dispatch: the signed unsubscribe routes after verify(),
24
24
  // or createUnsubscribePageChromeReader for a tenant the app resolved server-side.
25
25
  agent: { expose: false },
26
- description: "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route to dress its pages like the mail, not meant for UI callers.",
26
+ description: "Returns the tenant's mail brand row and footer resolved for one locale, or null without a mail renderer; dispatched by the signed unsubscribe route or by createUnsubscribePageChromeReader for a tenant the app resolved server-side, to dress its pages like the mail; not meant for UI callers.",
27
27
  handler: async (query, ctx) => {
28
28
  const { tenantId } = query.user;
29
29
  if (!ctx.systemDb) {
@@ -12,6 +12,7 @@ export { createDeliveryFeature, type DeliveryFeatureOptions } from "./feature.ts
12
12
  export { type UnsubscribeAddressPayload, unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.ts";
13
13
  export { currentNotificationType } from "./notification-type-aliases.ts";
14
14
  export { createDeliveryNotifyFactory } from "./notify-factory.ts";
15
+ export { buildSystemChannelContext, type SystemChannelContextDeps, type SystemChannelContextOptions, } from "./system-channel-context.ts";
15
16
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.ts";
16
17
  export { type CreateDeliveryTestContextOptions, createDeliveryTestContext } from "./testing.ts";
17
18
  export { type AttachmentOrigin, type ChannelContext, type ChannelMessage, type ChannelResult, type DeliveryChannel, type DeliveryChannelMode, type DeliveryChannelPlugin, type DeliveryLogEntry, type DeliveryService, isDeliveryChannelPlugin, type RenderedMessage, } from "./types.ts";
@@ -11,6 +11,7 @@ export { createDeliveryFeature } from "./feature.js";
11
11
  export { unsubscribeAddressPayloadSchema, } from "./handlers/unsubscribe-address.write.js";
12
12
  export { currentNotificationType } from "./notification-type-aliases.js";
13
13
  export { createDeliveryNotifyFactory } from "./notify-factory.js";
14
+ export { buildSystemChannelContext, } from "./system-channel-context.js";
14
15
  export { deliveryAttemptsTable, notificationAddressOptOutEntity, notificationAddressOptOutsTable, notificationPreferenceEntity, notificationPreferencesTable, } from "./tables.js";
15
16
  export { createDeliveryTestContext } from "./testing.js";
16
17
  export { isDeliveryChannelPlugin, } from "./types.js";