@cosmicdrift/kumiko-samples 0.363.0 → 0.365.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 (237) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +7 -1
  4. package/packages/bundled-features/src/audit/changes.json +18 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/handlers/list.query.ts +10 -5
  7. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  8. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +30 -11
  9. package/packages/bundled-features/src/auth-email-password/changes.json +55 -0
  10. package/packages/bundled-features/src/auth-email-password/email-rate-limits.ts +16 -0
  11. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  12. package/packages/bundled-features/src/auth-email-password/handlers/change-password.write.ts +6 -2
  13. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  14. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  15. package/packages/bundled-features/src/auth-email-password/handlers/logout.write.ts +7 -2
  16. package/packages/bundled-features/src/auth-email-password/handlers/signup-request.write.ts +2 -1
  17. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  18. package/packages/bundled-features/src/auth-email-password/handlers/token-request-handler.ts +2 -1
  19. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  20. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  21. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +24 -5
  22. package/packages/bundled-features/src/billing-foundation/changes.json +48 -0
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +57 -5
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +22 -1
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +4 -1
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/register-termination.ts +11 -1
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-mail.ts +9 -3
  28. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +41 -0
  29. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +18 -2
  30. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +35 -19
  31. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +9 -0
  32. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +35 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +11 -3
  34. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +10 -4
  35. package/packages/bundled-features/src/billing-foundation/i18n.ts +9 -0
  36. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +27 -7
  37. package/packages/bundled-features/src/billing-foundation/types.ts +7 -2
  38. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  39. package/packages/bundled-features/src/cap-counter/calendar-cap-usage-reservation.ts +2 -0
  40. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  41. package/packages/bundled-features/src/cap-counter/changes.json +19 -0
  42. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +75 -5
  43. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  44. package/packages/bundled-features/src/cap-counter/index.ts +1 -0
  45. package/packages/bundled-features/src/cap-counter/with-cap-enforcement.ts +3 -0
  46. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  47. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  48. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  49. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  50. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  51. package/packages/bundled-features/src/channel-email/email-channel.ts +47 -24
  52. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  53. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  54. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  55. package/packages/bundled-features/src/config/changes.json +6 -0
  56. package/packages/bundled-features/src/config/handlers/set.write.ts +2 -2
  57. package/packages/bundled-features/src/config/resolver.ts +15 -12
  58. package/packages/bundled-features/src/config/write-helpers.ts +23 -0
  59. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  60. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +54 -27
  61. package/packages/bundled-features/src/custom-fields/changes.json +6 -0
  62. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  63. package/packages/bundled-features/src/custom-fields/handlers/update-tenant-field.write.ts +0 -1
  64. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  65. package/packages/bundled-features/src/delivery/attachment-ref.ts +39 -1
  66. package/packages/bundled-features/src/delivery/changes.json +30 -0
  67. package/packages/bundled-features/src/delivery/channel-context.ts +31 -8
  68. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  69. package/packages/bundled-features/src/delivery/index.ts +2 -0
  70. package/packages/bundled-features/src/delivery/jobs.ts +51 -14
  71. package/packages/bundled-features/src/delivery/types.ts +7 -1
  72. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  73. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  74. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  75. package/packages/bundled-features/src/file-derivatives/handlers/public-variant.query.ts +1 -1
  76. package/packages/bundled-features/src/file-foundation/changes.json +6 -0
  77. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +6 -3
  78. package/packages/bundled-features/src/files-tenant-data/changes.json +6 -0
  79. package/packages/bundled-features/src/files-tenant-data/hooks.ts +4 -0
  80. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  81. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  82. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  83. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +13 -8
  85. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +3 -1
  86. package/packages/bundled-features/src/jobs/changes.json +31 -0
  87. package/packages/bundled-features/src/jobs/db/queries/stale-run-sweep.ts +12 -4
  88. package/packages/bundled-features/src/jobs/feature.ts +7 -4
  89. package/packages/bundled-features/src/jobs/handlers/list.query.ts +3 -1
  90. package/packages/bundled-features/src/jobs/handlers/stale-run-sweep.job.ts +5 -2
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +56 -18
  92. package/packages/bundled-features/src/jobs/tenant-job-run-table.ts +7 -2
  93. package/packages/bundled-features/src/ledger/changes.json +7 -0
  94. package/packages/bundled-features/src/ledger/entity.ts +3 -1
  95. package/packages/bundled-features/src/metrics/changes.json +6 -0
  96. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  97. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  98. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  99. package/packages/bundled-features/src/renderer-foundation/types.ts +2 -0
  100. package/packages/bundled-features/src/renderer-pdf/changes.json +12 -0
  101. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +162 -82
  102. package/packages/bundled-features/src/secrets/changes.json +12 -0
  103. package/packages/bundled-features/src/secrets/feature.ts +4 -3
  104. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  105. package/packages/bundled-features/src/secrets/secrets-context.ts +23 -3
  106. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  107. package/packages/bundled-features/src/shared/changes.json +6 -0
  108. package/packages/bundled-features/src/shared/chunked-entity-migration.ts +4 -1
  109. package/packages/bundled-features/src/shared/index.ts +8 -0
  110. package/packages/bundled-features/src/shared/mail-template-body.ts +17 -0
  111. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  112. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  113. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  114. package/packages/bundled-features/src/subscription-stripe/changes.json +6 -0
  115. package/packages/bundled-features/src/subscription-stripe/plugin-methods.ts +19 -11
  116. package/packages/bundled-features/src/subscription-stripe/verify-webhook.ts +3 -1
  117. package/packages/bundled-features/src/template-resolver/README.md +4 -2
  118. package/packages/bundled-features/src/template-resolver/api.ts +3 -2
  119. package/packages/bundled-features/src/template-resolver/changes.json +149 -0
  120. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +148 -18
  121. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  122. package/packages/bundled-features/src/template-resolver/constants.ts +23 -3
  123. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  124. package/packages/bundled-features/src/template-resolver/document-schema.ts +33 -5
  125. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  126. package/packages/bundled-features/src/template-resolver/feature.ts +16 -1
  127. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  128. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  129. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +121 -53
  130. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +6 -44
  131. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +52 -0
  132. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +56 -1
  133. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +6 -3
  134. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  135. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  136. package/packages/bundled-features/src/template-resolver/handlers/layout-source.query.ts +73 -30
  137. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +14 -11
  138. package/packages/bundled-features/src/template-resolver/handlers/override-diff.ts +49 -0
  139. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +102 -19
  140. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +49 -10
  141. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +39 -10
  142. package/packages/bundled-features/src/template-resolver/handlers/template-gates.ts +14 -2
  143. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +54 -4
  144. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +96 -14
  145. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +51 -24
  146. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +1 -1
  147. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +16 -2
  148. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  149. package/packages/bundled-features/src/template-resolver/index.ts +18 -2
  150. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +56 -0
  151. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  152. package/packages/bundled-features/src/template-resolver/pdf-constants.ts +12 -0
  153. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  154. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  155. package/packages/bundled-features/src/template-resolver/seeding.ts +27 -4
  156. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  157. package/packages/bundled-features/src/template-resolver/table.ts +13 -1
  158. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  159. package/packages/bundled-features/src/template-resolver/template-draft.ts +106 -0
  160. package/packages/bundled-features/src/template-resolver/template-meta.ts +95 -24
  161. package/packages/bundled-features/src/template-resolver/template-preview.ts +160 -57
  162. package/packages/bundled-features/src/template-resolver/template-resources.ts +3 -4
  163. package/packages/bundled-features/src/template-resolver/template-usage.ts +31 -11
  164. package/packages/bundled-features/src/template-resolver/template-validation.ts +16 -9
  165. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +12 -8
  166. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +97 -10
  167. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +15 -5
  168. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +405 -145
  169. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +17 -3
  170. package/packages/bundled-features/src/template-resolver/web/i18n.ts +32 -4
  171. package/packages/bundled-features/src/template-resolver/web/index.ts +8 -0
  172. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +23 -7
  173. package/packages/bundled-features/src/tenant/changes.json +6 -0
  174. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  175. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  176. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  177. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  178. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  179. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  180. package/packages/bundled-features/src/tier-engine/changes.json +18 -0
  181. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  182. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  183. package/packages/bundled-features/src/tier-engine/feature.ts +11 -3
  184. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  185. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  186. package/packages/bundled-features/src/user/changes.json +6 -0
  187. package/packages/bundled-features/src/user/constants.ts +1 -0
  188. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  189. package/packages/bundled-features/src/user/resolve-display-names.ts +8 -15
  190. package/packages/bundled-features/src/user/seeding.ts +19 -5
  191. package/packages/bundled-features/src/user-data-rights/changes.json +12 -0
  192. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  193. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  194. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  195. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  196. package/packages/bundled-features/src/user-profile/changes.json +1 -1
  197. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  198. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  199. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  200. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  201. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  202. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  203. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  204. package/packages/bundled-features/src/waitlist/options.ts +1 -1
  205. package/packages/bundled-features/src/workflow-runner/changes.json +6 -0
  206. package/packages/bundled-features/src/workflow-runner/event-trigger.ts +2 -1
  207. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  208. package/packages/bundled-features/src/workflow-runner/handlers/resume-run.write.ts +7 -1
  209. package/samples/apps/ui-walkthrough/package.json +9 -0
  210. package/samples/apps/ui-walkthrough/playwright.config.ts +40 -7
  211. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +87 -2
  212. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +246 -30
  213. package/samples/apps/ui-walkthrough/src/features/billing/feature.ts +4 -0
  214. package/samples/apps/ui-walkthrough/src/run-config.ts +18 -1
  215. package/samples/apps/use-all-bundled/README.md +3 -13
  216. package/samples/apps/use-all-bundled/feature-manifest.json +7 -5
  217. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  218. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  219. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  220. package/samples/recipes/auth-mfa/README.md +2 -1
  221. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  222. package/samples/recipes/custom-handlers/README.md +4 -3
  223. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  224. package/samples/recipes/dashboard-stats/README.md +38 -0
  225. package/samples/recipes/dashboard-stats/package.json +8 -0
  226. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  227. package/samples/recipes/legal-pages/README.md +2 -2
  228. package/samples/recipes/managed-pages/README.md +2 -2
  229. package/samples/recipes/pdf-audit-export/README.md +2 -1
  230. package/samples/recipes/pdf-audit-export/package.json +1 -1
  231. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  232. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  233. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  234. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  235. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  236. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  237. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -23,7 +23,7 @@
23
23
  // hand-picked priceId.
24
24
 
25
25
  import type { HandlerContext, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
26
- import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
26
+ import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
27
27
  import * as z from "zod";
28
28
  import {
29
29
  assertCheckoutAllowed,
@@ -32,6 +32,7 @@ import {
32
32
  startProviderCheckout,
33
33
  } from "../checkout-core.js";
34
34
  import {
35
+ assertValidOrderItem,
35
36
  type ConsentPayload,
36
37
  consentCheckoutFields,
37
38
  consentPayloadSchema,
@@ -104,11 +105,12 @@ export function createCheckoutSessionHandler(
104
105
 
105
106
  const mode = payload.mode ?? "subscription";
106
107
  const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
107
- const price = await lookupPrice(ctx, plugin, payload.priceId);
108
+ const price = await lookupPrice(ctx, plugin, payload.providerName, payload.priceId);
108
109
  const orderItem =
109
110
  mode === "payment"
110
111
  ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
111
112
  : undefined;
113
+ assertValidOrderItem(orderItem, payload.priceId);
112
114
  const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
113
115
  const result = await startProviderCheckout(ctx, plugin, {
114
116
  ...checkoutInput,
@@ -134,11 +136,17 @@ export function createCheckoutSessionHandler(
134
136
  async function lookupPrice(
135
137
  ctx: HandlerContext,
136
138
  plugin: SubscriptionProviderPlugin,
139
+ providerName: string,
137
140
  priceId: string,
138
141
  ): Promise<ProviderPrice> {
142
+ if (!plugin.retrievePrices) {
143
+ throw new InternalError({
144
+ message: `billing-foundation: provider "${providerName}" lacks retrievePrices, required by consumerProtection`,
145
+ });
146
+ }
139
147
  let price: ProviderPrice | undefined;
140
148
  try {
141
- const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
149
+ const prices = await plugin.retrievePrices(ctx, [priceId]);
142
150
  price = prices.find((p) => p.priceId === priceId);
143
151
  } catch (cause) {
144
152
  throw priceUnavailable(priceId, cause);
@@ -21,6 +21,7 @@ import * as z from "zod";
21
21
  import { subscriptionAggregateId } from "../aggregate-id.js";
22
22
  import {
23
23
  isSubscriptionBlockingCheckout,
24
+ isSwitchableSubscriptionStatus,
24
25
  SubscriptionEventTypes,
25
26
  SubscriptionStatuses,
26
27
  } from "../constants.js";
@@ -199,19 +200,24 @@ export async function appendSubscriptionEvent(
199
200
  // Tenant-Admin direkt.
200
201
  // One subscription aggregate exists per tenant, but a tenant can legitimately hold two provider
201
202
  // subscriptions over time (a stale `incomplete` one superseded by a fresh checkout). A late or
202
- // retried event of the old subscription must not overwrite the row of the live one, so only a
203
- // `created` event may replace a blocking row that belongs to a different provider subscription.
203
+ // retried event of the old subscription must not overwrite the row of the live one, so an event of a
204
+ // different provider subscription only replaces a row that no longer blocks a checkout. The exception
205
+ // is a `created` of a subscription that is itself live (a provider switch); a late `created` of a
206
+ // never-completed `incomplete` one stays stale, since delivery order is not guaranteed.
204
207
  function isStaleSubscriptionEvent(
205
208
  current: SubscriptionView | null,
206
209
  payload: ProcessEventPayload,
207
210
  now: Temporal.Instant,
208
211
  ): boolean {
209
212
  if (current === null) return false;
210
- if (payload.type === SubscriptionEventTypes.created) return false;
211
213
  const sameSubscription =
212
214
  current.providerName === payload.providerName &&
213
215
  current.providerSubscriptionId === payload.providerSubscriptionId;
214
- return !sameSubscription && isSubscriptionBlockingCheckout(current, now);
216
+ if (sameSubscription || !isSubscriptionBlockingCheckout(current, now)) return false;
217
+ const supersedesCurrent =
218
+ payload.type === SubscriptionEventTypes.created &&
219
+ isSwitchableSubscriptionStatus(payload.status);
220
+ return !supersedesCurrent;
215
221
  }
216
222
 
217
223
  export function createProcessEventHandler(now: () => Temporal.Instant): WriteHandlerDef {
@@ -4,6 +4,12 @@
4
4
  type LocalizedString = { readonly en: string };
5
5
 
6
6
  export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>> = {
7
+ "billing-foundation:feature.label": { en: "Billing" },
8
+ "billing-foundation:notify.contract-confirmation.label": { en: "Contract confirmation" },
9
+ "billing-foundation:notify.contract-termination-receipt.label": { en: "Termination receipt" },
10
+ "billing-foundation:notify.contract-termination-operator-notice.label": {
11
+ en: "Termination notice to operator",
12
+ },
7
13
  "screen:billing-plans.title": { en: "Billing" },
8
14
  "billing-foundation.plans.billingDisabled": {
9
15
  en: "Billing is not active yet. Your current plan stays in place.",
@@ -95,6 +101,9 @@ export const BILLING_FOUNDATION_I18N: Readonly<Record<string, LocalizedString>>
95
101
  "billing-foundation.errors.redirectOriginNotAllowed": {
96
102
  en: "This redirect URL is not allowed for this app.",
97
103
  },
104
+ "billing-foundation.errors.providerCustomerUnknown": {
105
+ en: "The billing account for this subscription no longer exists. Subscribe again to continue.",
106
+ },
98
107
  "billing-foundation.errors.providerHasNoPriceCatalog": {
99
108
  en: "This provider has no known prices configured.",
100
109
  },
@@ -4,6 +4,8 @@
4
4
  // so it is now a factory parameter.
5
5
 
6
6
  import {
7
+ isTierAssignmentSource,
8
+ type TierAssignmentSource,
7
9
  TierAssignmentSources,
8
10
  TierEngineHandlers,
9
11
  TierEngineQueries,
@@ -38,6 +40,10 @@ export type SubscriptionTierSyncDeps<TTier extends string> = {
38
40
  readonly onSyncError?: "log" | "fail-webhook";
39
41
  };
40
42
 
43
+ // A manual grant that differs from what billing would assign is reported with this
44
+ // code but never fails the webhook: a provider retry cannot resolve it.
45
+ export const TIER_SYNC_MANUAL_GRANT_SKIPPED = "tier_sync_manual_grant_skipped";
46
+
41
47
  export function effectiveTierFromSubscription<TTier extends string>(
42
48
  status: string | undefined,
43
49
  tier: string | undefined,
@@ -63,7 +69,7 @@ type ExistingTierAssignment = {
63
69
  readonly id: string;
64
70
  readonly version: number;
65
71
  readonly tier: unknown;
66
- readonly source: unknown;
72
+ readonly source: TierAssignmentSource | null;
67
73
  };
68
74
 
69
75
  function findTierAssignment(
@@ -74,7 +80,13 @@ function findTierAssignment(
74
80
  if (!row || typeof row["id"] !== "string" || typeof row["version"] !== "number") {
75
81
  return undefined;
76
82
  }
77
- return { id: row["id"], version: row["version"], tier: row["tier"], source: row["source"] };
83
+ const source = row["source"];
84
+ return {
85
+ id: row["id"],
86
+ version: row["version"],
87
+ tier: row["tier"],
88
+ source: isTierAssignmentSource(source) ? source : null,
89
+ };
78
90
  }
79
91
 
80
92
  export function createSubscriptionTierSync<TTier extends string>(
@@ -82,7 +94,7 @@ export function createSubscriptionTierSync<TTier extends string>(
82
94
  ) {
83
95
  async function syncTierFromSubscription(
84
96
  tenantId: TenantId,
85
- routeDeps: SignatureExtraRouteDeps,
97
+ routeDeps: Pick<SignatureExtraRouteDeps, "dispatchSystemQuery" | "dispatchSystemWrite">,
86
98
  ): Promise<{ code: string; message: string } | null> {
87
99
  try {
88
100
  const subscriptionId = subscriptionAggregateId(tenantId);
@@ -129,8 +141,13 @@ export function createSubscriptionTierSync<TTier extends string>(
129
141
  }
130
142
  return null;
131
143
  }
132
- if (assignment.source === TierAssignmentSources.manual) return null;
133
144
  if (assignment.tier === effective) return null;
145
+ if (assignment.source === TierAssignmentSources.manual) {
146
+ return {
147
+ code: TIER_SYNC_MANUAL_GRANT_SKIPPED,
148
+ message: `manual tier grant "${String(assignment.tier)}" kept although the subscription yields "${effective}"`,
149
+ };
150
+ }
134
151
 
135
152
  const result = await routeDeps.dispatchSystemWrite({
136
153
  handlerQn: TierEngineHandlers.update,
@@ -169,9 +186,12 @@ export function createSubscriptionTierSync<TTier extends string>(
169
186
  if (syncError) {
170
187
  // biome-ignore lint/suspicious/noConsole: operator visibility for a post-commit sync failure
171
188
  console.warn(
172
- `[subscription-tier-sync] tier sync failed for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
189
+ `[subscription-tier-sync] tier sync for tenant ${tenantId} after successful webhook write: ${syncError.code} ${syncError.message}`,
173
190
  );
174
- if (deps.onSyncError === "fail-webhook") {
191
+ if (
192
+ deps.onSyncError === "fail-webhook" &&
193
+ syncError.code !== TIER_SYNC_MANUAL_GRANT_SKIPPED
194
+ ) {
175
195
  return {
176
196
  isSuccess: false,
177
197
  error: {
@@ -188,5 +208,5 @@ export function createSubscriptionTierSync<TTier extends string>(
188
208
  });
189
209
  }
190
210
 
191
- return { createWebhookRoute };
211
+ return { createWebhookRoute, syncTierFromSubscription };
192
212
  }
@@ -396,7 +396,10 @@ export function isLegalLinkSet(
396
396
 
397
397
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
398
398
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
399
- * payload and record it as an event before the buyer reaches the provider. */
399
+ * payload and record it as an event before the buyer reaches the provider.
400
+ * Every provider used with `create-checkout-session` must implement
401
+ * `retrievePrices`: the live price is part of the consent record, and a
402
+ * provider without it fails the checkout with an InternalError. */
400
403
  export type ConsumerProtectionOptions = {
401
404
  /** Slug of the template-resolver `text-block` holding the terms text; its
402
405
  * content hash is stored with every recorded consent. */
@@ -411,7 +414,9 @@ export type ConsumerProtectionOptions = {
411
414
  readonly terminationScope?: TerminationScope;
412
415
  /** Names the item of a one-off payment (mode "payment") for the consent record
413
416
  * and the confirmation mail; the label key is resolved through the registry
414
- * i18n, so it must be qualified with the registering feature. */
417
+ * i18n, so it must be qualified with the registering feature. The label key
418
+ * is 1-200 characters, string params at most 100; a violation fails the
419
+ * checkout with an InternalError before the provider session is created. */
415
420
  readonly oneOffItemLabel?: (
416
421
  ctx: HandlerContext,
417
422
  priceId: string,
@@ -280,6 +280,12 @@ export type ReadRollingCapUsageOptions = {
280
280
  readonly windowDays: number;
281
281
  };
282
282
 
283
+ // A release is written up to a reservation TTL after its increment; once the increment left the
284
+ // window the release must not subtract from other increments still inside it.
285
+ function isBefore(isoInstant: string, cutoff: Temporal.Instant): boolean {
286
+ return Temporal.Instant.compare(Temporal.Instant.from(isoInstant), cutoff) < 0;
287
+ }
288
+
283
289
  async function sumRollingEventAmounts(
284
290
  db: TenantDb,
285
291
  tenantId: TenantId,
@@ -289,20 +295,26 @@ async function sumRollingEventAmounts(
289
295
  const aggregateId = rollingCapAggregateId(tenantId, options.capName);
290
296
  const cutoff = Temporal.Now.instant().subtract({ hours: options.windowDays * 24 });
291
297
 
292
- const rows = await db.selectMany<{ payload: { amount?: number } }>(eventsTable, {
293
- tenantId,
294
- aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
295
- aggregateId,
296
- type,
297
- createdAt: { gte: cutoff },
298
- });
298
+ const rows = await db.selectMany<{ payload: { amount?: number; incrementedAt?: string } }>(
299
+ eventsTable,
300
+ {
301
+ tenantId,
302
+ aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
303
+ aggregateId,
304
+ type,
305
+ createdAt: { gte: cutoff },
306
+ },
307
+ );
299
308
 
300
309
  let total = 0;
301
310
  for (const row of rows) {
302
311
  // @cast-boundary engine-payload — events.payload is jsonb (typed as
303
312
  // unknown by drizzle's $type<Record<string,unknown>>); narrowing the
304
313
  // shape here mirrors enforceRollingCap's own read-side contract.
305
- const payload = row["payload"] as { amount?: number };
314
+ const payload = row["payload"] as { amount?: number; incrementedAt?: string };
315
+ if (typeof payload.incrementedAt === "string" && isBefore(payload.incrementedAt, cutoff)) {
316
+ continue;
317
+ }
306
318
  if (typeof payload.amount === "number") {
307
319
  total += payload.amount;
308
320
  }
@@ -44,6 +44,8 @@ export async function reserveEnforcedCalendarCap(
44
44
  profile: cap.profile,
45
45
  notify: cap.notify,
46
46
  amount: cap.amount,
47
+ ...(cap.periodEndIso !== undefined && { periodEndIso: cap.periodEndIso }),
48
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
47
49
  markSoftWarnedOutsideTransaction: outsideTransaction,
48
50
  });
49
51
  }
@@ -37,6 +37,7 @@ type CapReservationRow = {
37
37
  readonly periodStartIso: string | null;
38
38
  readonly amount: number;
39
39
  readonly expiresAt: Temporal.Instant;
40
+ readonly createdAt: Temporal.Instant;
40
41
  };
41
42
 
42
43
  const NOTHING_TO_RELEASE: WriteResult = { isSuccess: true, data: {} };
@@ -136,13 +137,14 @@ function rollingEvent(
136
137
  amount: number,
137
138
  expectedVersion: number,
138
139
  type: string,
140
+ extraPayload: { readonly incrementedAt?: string } = {},
139
141
  ) {
140
142
  return {
141
143
  aggregateId: rollingCapAggregateId(user.tenantId, capName),
142
144
  aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
143
145
  expectedVersion,
144
146
  type,
145
- payload: { capName, amount },
147
+ payload: { capName, amount, ...extraPayload },
146
148
  metadata: { userId: user.id },
147
149
  };
148
150
  }
@@ -213,7 +215,10 @@ async function giveBackRolling(
213
215
  );
214
216
  await appendEventInTenantDb(
215
217
  txDb,
216
- rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN),
218
+ // The window filter must use the increment's time: a sweep releases up to a TTL later.
219
+ rollingEvent(user, row.capName, row.amount, version, ROLLING_RELEASED_EVENT_QN, {
220
+ incrementedAt: row.createdAt.toString(),
221
+ }),
217
222
  );
218
223
  return NOTHING_TO_RELEASE;
219
224
  }
@@ -1,4 +1,23 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "breaking",
5
+ "title": "Handlers wrapped with withCapEnforcement or withRollingCapEnforcement are rejected when called through a nested ctx.write",
6
+ "detail": "Since the wrappers reserve through reserveBeforeTransaction, the dispatcher rejects a nested ctx.write, ctx.writeAs or handler-to-handler call of such a handler with an internal error (\"has a pre-transaction phase ... cannot run as a nested write\") instead of running it. Before the reservation lifecycle change the wrapped handler ran normally when called nested.",
7
+ "migration": "Dispatch rolling- and calendar-capped handlers only as top-level commands (HTTP write or batch). A handler that calls one through ctx.write declares the cap on itself instead, or the call moves out of the handler into a client or job that dispatches a top-level command."
8
+ },
9
+ {
10
+ "version": "0.365.0",
11
+ "type": "fix",
12
+ "title": "Rolling cap release remembers the original booking time",
13
+ "detail": "rolling-released events now carry the incrementedAt of the booking they return, so readRollingCapUsage no longer subtracts a release whose increment already left the rolling window."
14
+ },
15
+ {
16
+ "version": "0.364.0",
17
+ "type": "breaking",
18
+ "title": "CapExceededError carries resetAt and retryAfterSeconds, API sets Retry-After",
19
+ "migration": "Periodic caps no longer need RateLimitError({ windowSeconds: 0, ... }). Where you catch CapExceededError from enforceCap and rethrow it as RateLimitError, drop the catch and let CapExceededError pass: the response stays 429 with a correct Retry-After, error.code becomes cap_exceeded instead of rate_limited, and error.details carries resetAt and retryAfterSeconds. Keep a feature-specific message with enforceCap({ ..., i18nKey }) and pass a period end other than the calendar month with periodEndIso (e.g. a budget's resetsAtIso). Clients matching on rate_limited for these caps must also accept cap_exceeded. Any KumikoError with retryAfterSeconds (including RateLimitError thrown in a handler) now gets a Retry-After header from /api/write, /api/batch and /api/query."
20
+ },
2
21
  {
3
22
  "version": "0.361.0",
4
23
  "type": "improvement",
@@ -66,14 +66,38 @@ export function assertBelowHardCap(
66
66
  readonly capName: string;
67
67
  readonly limit: number;
68
68
  readonly profile: CapToleranceProfileName;
69
+ // Rolling caps have no fixed period, so they pass neither period field.
70
+ readonly periodStartIso?: string;
71
+ readonly periodEndIso?: string;
72
+ readonly i18nKey?: string;
69
73
  },
70
74
  ): void {
71
75
  const tolerance = CAP_TOLERANCES[cap.profile];
72
76
  if (valueSeenByLastUnit >= cap.limit * tolerance.hard) {
73
- throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance);
77
+ const resetAt = resolvePeriodEndIso(cap.periodStartIso, cap.periodEndIso);
78
+ throw new CapExceededError(cap.capName, cap.limit, valueSeenByLastUnit, tolerance, {
79
+ ...(resetAt !== undefined && { resetAt }),
80
+ ...(cap.i18nKey !== undefined && { i18nKey: cap.i18nKey }),
81
+ });
74
82
  }
75
83
  }
76
84
 
85
+ function resolvePeriodEndIso(
86
+ periodStartIso: string | undefined,
87
+ periodEndIso: string | undefined,
88
+ ): string | undefined {
89
+ if (periodStartIso === undefined) return periodEndIso;
90
+ if (periodEndIso === undefined) return calendarMonthPeriodEndIso(periodStartIso);
91
+ const start = Temporal.Instant.from(periodStartIso);
92
+ const end = Temporal.Instant.from(periodEndIso);
93
+ if (Temporal.Instant.compare(end, start) <= 0) {
94
+ throw new Error(
95
+ `cap-counter: periodEndIso (${periodEndIso}) must be after periodStartIso (${periodStartIso})`,
96
+ );
97
+ }
98
+ return end.toString();
99
+ }
100
+
77
101
  // =============================================================================
78
102
  // Enforce-Cap helper
79
103
  // =============================================================================
@@ -109,6 +133,9 @@ export async function enforceCap(
109
133
  // Units the caller is about to book in one go (default 1), checked with
110
134
  // the same outcome as booking them one by one.
111
135
  readonly amount?: number;
136
+ // Default: end of the calendar month that starts at periodStartIso.
137
+ readonly periodEndIso?: string;
138
+ readonly i18nKey?: string;
112
139
  },
113
140
  ): Promise<EnforceCapResult> {
114
141
  const { user, readDb } = resolveCapCounterScope(ctx, false);
@@ -211,23 +238,54 @@ export async function enforceRollingCap(
211
238
  * Thrown by enforceCap/enforceRollingCap when value ≥ hard-threshold.
212
239
  * Extends `KumikoError` — the dispatcher auto-maps this to HTTP 429
213
240
  * with `code: "cap_exceeded"`, no caller-side catch needed.
241
+ *
242
+ * Calendar caps carry `resetAt` (end of the period) and `retryAfterSeconds`, which the API
243
+ * answers as Retry-After. Rolling caps have no fixed reset instant, so both stay undefined.
214
244
  */
215
245
  export class CapExceededError extends KumikoError {
216
246
  readonly code = "cap_exceeded" as const;
217
247
  readonly httpStatus = 429;
248
+ readonly resetAt: string | undefined;
218
249
 
219
250
  constructor(
220
251
  readonly capName: string,
221
252
  readonly limit: number,
222
253
  readonly currentValue: number,
223
254
  readonly tolerance: CapToleranceProfile,
255
+ options?: {
256
+ readonly resetAt?: string;
257
+ readonly i18nKey?: string;
258
+ readonly cause?: Error;
259
+ // Deterministic tests only.
260
+ readonly now?: Temporal.Instant;
261
+ },
224
262
  ) {
263
+ const resetAt = options?.resetAt;
264
+ const retryAfterSeconds =
265
+ resetAt === undefined
266
+ ? undefined
267
+ : Math.max(
268
+ 0,
269
+ Math.ceil(
270
+ Temporal.Instant.from(resetAt).epochMilliseconds / 1000 -
271
+ (options?.now ?? Temporal.Now.instant()).epochMilliseconds / 1000,
272
+ ),
273
+ );
225
274
  super({
226
- message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.`,
227
- i18nKey: "errors.cap.exceeded",
228
- i18nParams: { capName, limit, currentValue },
229
- details: { capName, limit, currentValue, tolerance },
275
+ message: `Cap "${capName}" exceeded: current=${currentValue}, limit=${limit}, hard-threshold=${limit * tolerance.hard}. Upgrade tier or wait for next period reset.${resetAt === undefined ? "" : ` Resets at ${resetAt}.`}`,
276
+ i18nKey: options?.i18nKey ?? "errors.cap.exceeded",
277
+ i18nParams: { capName, limit, currentValue, ...(resetAt !== undefined && { resetAt }) },
278
+ details: {
279
+ capName,
280
+ limit,
281
+ currentValue,
282
+ tolerance,
283
+ ...(resetAt !== undefined && { resetAt, retryAfterSeconds }),
284
+ },
285
+ ...(retryAfterSeconds !== undefined && { retryAfterSeconds }),
286
+ ...(options?.cause && { cause: options.cause }),
230
287
  });
288
+ this.resetAt = resetAt;
231
289
  }
232
290
  }
233
291
 
@@ -257,6 +315,14 @@ export function currentCalendarMonthStartIso(
257
315
  return start.toInstant().toString();
258
316
  }
259
317
 
318
+ export function calendarMonthPeriodEndIso(periodStartIso: string): string {
319
+ return Temporal.Instant.from(periodStartIso)
320
+ .toZonedDateTimeISO("UTC")
321
+ .add({ months: 1 })
322
+ .toInstant()
323
+ .toString();
324
+ }
325
+
260
326
  // =============================================================================
261
327
  // Notification-Wiring helpers — convenience-wrapper für enforceCap +
262
328
  // enforceRollingCap, die einen Caller-supplied delivery-emit beim
@@ -299,6 +365,8 @@ export async function enforceCapAndMaybeNotify(
299
365
  readonly profile: CapToleranceProfileName;
300
366
  readonly notify: SoftHitNotifier;
301
367
  readonly amount?: number;
368
+ readonly periodEndIso?: string;
369
+ readonly i18nKey?: string;
302
370
  readonly markSoftWarnedOutsideTransaction?: boolean;
303
371
  },
304
372
  ): Promise<EnforceCapResult> {
@@ -308,6 +376,8 @@ export async function enforceCapAndMaybeNotify(
308
376
  limit: options.limit,
309
377
  profile: options.profile,
310
378
  ...(options.amount !== undefined && { amount: options.amount }),
379
+ ...(options.periodEndIso !== undefined && { periodEndIso: options.periodEndIso }),
380
+ ...(options.i18nKey !== undefined && { i18nKey: options.i18nKey }),
311
381
  });
312
382
 
313
383
  if (result.state === "soft-hit" && result.crossed) {
@@ -40,6 +40,8 @@ export const rollingIncrementedSchema = incrementRollingSchema;
40
40
  /** Payload of the event that gives a reserved rolling amount back. */
41
41
  export const rollingReleasedSchema = incrementRollingSchema.extend({
42
42
  amount: z.number().int().positive(),
43
+ /** When the released increment was booked; absent on events written before it was recorded. */
44
+ incrementedAt: z.string().optional(),
43
45
  });
44
46
 
45
47
  // Rolling-Increment-Handler — append-only. Race-frei: zwei parallele
@@ -29,6 +29,7 @@ export {
29
29
  CapExceededError,
30
30
  type CapToleranceProfile,
31
31
  type CapToleranceProfileName,
32
+ calendarMonthPeriodEndIso,
32
33
  currentCalendarMonthStartIso,
33
34
  type EnforceCapResult,
34
35
  enforceCap,
@@ -53,6 +53,9 @@ import { chainReservations } from "./reservation-chain.js";
53
53
  export type CalendarCapDef = {
54
54
  readonly capName: string;
55
55
  readonly periodStartIso: string;
56
+ // Default: end of the calendar month that starts at periodStartIso.
57
+ readonly periodEndIso?: string;
58
+ readonly i18nKey?: string;
56
59
  readonly limit: number;
57
60
  readonly profile: CapToleranceProfileName;
58
61
  /** Increment-amount post-success. Default 1. */
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Cap overview lists no destroyed tenants",
6
+ "detail": "tenant-options and tenant-caps-list now filter destroyed tenants and keep reading tenant names through the PII-aware path."
7
+ },
2
8
  {
3
9
  "version": "0.338.0",
4
10
  "type": "breaking",
@@ -176,7 +176,8 @@ export function createTenantCapsListQuery(caps: readonly CapSpec[], listCaps: re
176
176
  );
177
177
 
178
178
  const tenants = await decryptTenantNames(
179
- await selectMany<TenantRow>(db, tenantTable, {}),
179
+ // Destroyed tenants decrypt to the erased sentinel, which a picker or list would show verbatim.
180
+ await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
180
181
  "cap-overview:tenant-caps:list",
181
182
  );
182
183
  const assignments = await selectMany<TierAssignmentRow>(db, tierAssignmentTable, {});
@@ -23,7 +23,8 @@ export const tenantOptionsQuery = defineQueryHandler({
23
23
  "cap-overview:tenant-options — SystemAdmin dashboard tenant-filter options",
24
24
  );
25
25
  const tenants = await decryptTenantNames(
26
- await selectMany<TenantRow>(db, tenantTable, {}),
26
+ // Destroyed tenants decrypt to the erased sentinel, which the picker would show verbatim.
27
+ await selectMany<TenantRow>(db, tenantTable, { destroyedAt: null }),
27
28
  "cap-overview:tenant-options",
28
29
  );
29
30
  const sorted = [...tenants].sort((a, b) => a.name.localeCompare(b.name));
@@ -8,9 +8,11 @@ import {
8
8
 
9
9
  export type DiscordChannelOptions = ChatWebhookChannelOptions;
10
10
 
11
+ const DISCORD_WEBHOOK_PREFIX = "channel-discord:webhooks.";
12
+
11
13
  export const DISCORD_SECRET_KEYS = {
12
- webhookPrefix: "channel-discord:webhooks.",
13
- webhookKeyFor: (connection: string) => `channel-discord:webhooks.${connection}`,
14
+ webhookPrefix: DISCORD_WEBHOOK_PREFIX,
15
+ webhookKeyFor: (connection: string) => `${DISCORD_WEBHOOK_PREFIX}${connection}`,
14
16
  } as const;
15
17
 
16
18
  export const DISCORD_DEFAULT_ALLOWED_HOSTS = ["discord.com", "discordapp.com"] as const;
@@ -1,4 +1,22 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "SMTP display names drop address-like characters, inline images get matching extensions",
6
+ "detail": "Control characters, angle brackets and at signs in a From display name are replaced by spaces so it cannot smuggle a second address; inline image file names derive their extension from the shared image format list."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "List-Unsubscribe accepts an app-owned unsubscribe URL",
12
+ "detail": "When unsubscribeUrl in the mail data points to an http(s) URL outside the framework unsubscribe route, the email channel sets List-Unsubscribe to that URL without the one-click List-Unsubscribe-Post header, because the framework cannot vouch for a foreign endpoint accepting POST. Other schemes set no header."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "fix",
17
+ "title": "A mail whose file attachment is missing fails at once instead of retrying",
18
+ "detail": "A missing or foreign-tenant file ref ends the delivery attempt as failed (send_failed) without a retry. The send job no longer throws, so the idempotency key is released and the caller can resend. A template test mail whose template file is missing now answers not_sent instead of channel_error."
19
+ },
2
20
  {
3
21
  "version": "0.363.0",
4
22
  "type": "breaking",