@cosmicdrift/kumiko-bundled-features 0.343.0 → 0.345.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 (53) hide show
  1. package/dist/audit/run-escape-hatch-retention.js +25 -12
  2. package/dist/auth-email-password/constants.d.ts +1 -0
  3. package/dist/auth-email-password/constants.js +1 -0
  4. package/dist/auth-email-password/feature.js +6 -2
  5. package/dist/auth-email-password/handlers/invite-signup-complete.write.d.ts +6 -2
  6. package/dist/auth-email-password/handlers/invite-signup-complete.write.js +14 -4
  7. package/dist/auth-email-password/handlers/signup-confirm.write.d.ts +6 -2
  8. package/dist/auth-email-password/handlers/signup-confirm.write.js +8 -1
  9. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.d.ts +9 -0
  10. package/dist/auth-email-password/handlers/switch-tenant-mfa-gate.write.js +39 -0
  11. package/dist/auth-email-password/handlers/token-request-handler.js +7 -1
  12. package/dist/auth-email-password/i18n.js +1 -0
  13. package/dist/auth-email-password/web/auth-client.d.ts +6 -1
  14. package/dist/auth-email-password/web/auth-client.js +6 -2
  15. package/dist/auth-email-password/web/signup-complete-screen.js +9 -1
  16. package/dist/billing-foundation/checkout-core.d.ts +15 -4
  17. package/dist/billing-foundation/checkout-core.js +18 -6
  18. package/dist/billing-foundation/consumer-protection/confirmation-mail.d.ts +13 -3
  19. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +23 -20
  20. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +6 -1
  21. package/dist/billing-foundation/consumer-protection/consent-gate.js +6 -1
  22. package/dist/billing-foundation/consumer-protection/consent-locale.js +1 -1
  23. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +24 -2
  24. package/dist/billing-foundation/events.d.ts +2 -0
  25. package/dist/billing-foundation/events.js +2 -0
  26. package/dist/billing-foundation/handlers/billing-plans.query.d.ts +1 -1
  27. package/dist/billing-foundation/handlers/billing-plans.query.js +5 -1
  28. package/dist/billing-foundation/handlers/create-checkout-session.write.js +46 -24
  29. package/dist/billing-foundation/index.d.ts +3 -1
  30. package/dist/billing-foundation/index.js +2 -0
  31. package/dist/billing-foundation/types.d.ts +16 -7
  32. package/dist/billing-foundation/types.js +3 -0
  33. package/dist/billing-foundation/validate-options.d.ts +1 -1
  34. package/dist/billing-foundation/validate-options.js +24 -2
  35. package/dist/billing-foundation/web/billing-plans-panel.js +1 -1
  36. package/dist/billing-foundation/web/cancel-contract-dialog.js +2 -2
  37. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +1 -1
  38. package/dist/billing-foundation/web/checkout-consent-dialog.js +4 -4
  39. package/dist/billing-foundation/web/index.d.ts +3 -1
  40. package/dist/billing-foundation/web/index.js +2 -0
  41. package/dist/legal-pages/constants.d.ts +2 -0
  42. package/dist/legal-pages/feature.js +7 -2
  43. package/dist/renderer-simple/simple-renderer.js +8 -0
  44. package/dist/step-dispatcher/feature.js +2 -1
  45. package/dist/step-dispatcher/webhook-runner.d.ts +2 -0
  46. package/dist/step-dispatcher/webhook-runner.js +7 -0
  47. package/package.json +9 -9
  48. package/src/audit/changes.json +6 -0
  49. package/src/auth-email-password/changes.json +24 -0
  50. package/src/billing-foundation/changes.json +7 -0
  51. package/src/legal-pages/changes.json +6 -0
  52. package/src/renderer-simple/changes.json +6 -0
  53. package/src/step-dispatcher/changes.json +6 -0
@@ -1,7 +1,7 @@
1
1
  // @runtime client
2
2
  // Crypto-free so the browser panel can share the locale rule with the server.
3
3
  export const CONSENT_LOCALES = ["de", "en"];
4
- export const FALLBACK_CONSENT_LOCALE = "de";
4
+ export const FALLBACK_CONSENT_LOCALE = "en";
5
5
  function isConsentLocale(value) {
6
6
  return CONSENT_LOCALES.some((locale) => locale === value);
7
7
  }
@@ -1,8 +1,10 @@
1
1
  // § 312f contract confirmation: one mail per recorded consent, sent by the
2
2
  // system-only issue-contract-confirmation handler that the event-triggered
3
3
  // jobs (register-consent.ts) call.
4
- import { access, createSystemUser, } from "@cosmicdrift/kumiko-framework/engine";
4
+ import { access, createSystemUser, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
5
5
  import { InternalError, UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
6
+ import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
7
+ import { isValidIanaTimeZone } from "@cosmicdrift/kumiko-framework/time";
6
8
  import * as z from "zod";
7
9
  import { requireTemplateResolver, TEXT_BLOCK_KIND } from "../../template-resolver/index.js";
8
10
  import { UserQueries } from "../../user/index.js";
@@ -22,7 +24,17 @@ export const issueContractConfirmationSchema = z
22
24
  const recipientRowSchema = z.object({
23
25
  email: z.string().min(1),
24
26
  isDeleted: z.boolean().optional(),
27
+ timezone: z.string().nullish(),
25
28
  });
29
+ // createI18n returns the key itself for an unknown key.
30
+ function translatedOrUndefined(translation, key) {
31
+ return translation === key ? undefined : translation;
32
+ }
33
+ function pickTimeZone(userZone, tenantZone) {
34
+ if (userZone && isValidIanaTimeZone(userZone))
35
+ return userZone;
36
+ return isValidIanaTimeZone(tenantZone) ? tenantZone : "UTC";
37
+ }
26
38
  export function createIssueContractConfirmationHandler(options, consumerProtection) {
27
39
  return {
28
40
  name: "issue-contract-confirmation",
@@ -100,7 +112,7 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
100
112
  }
101
113
  const locale = resolveConsentLocale(consent.locale);
102
114
  const terms = await requireTemplateResolver(ctx, "billing-foundation").resolveTemplate({
103
- tenantId,
115
+ tenantId: SYSTEM_TENANT_ID,
104
116
  slug: consumerProtection.termsTextBlock,
105
117
  kind: TEXT_BLOCK_KIND,
106
118
  locale,
@@ -108,9 +120,19 @@ export function createIssueContractConfirmationHandler(options, consumerProtecti
108
120
  if (terms.version !== consent.termsTemplateVersion) {
109
121
  ctx.log?.warn(`[billing-foundation:issue-contract-confirmation] terms text block changed since consent ${payload.consentId} (version ${consent.termsTemplateVersion} -> ${terms.version}); the mail carries the current text`);
110
122
  }
123
+ const i18n = createI18n(ctx.registry, { defaultLocale: "en" });
124
+ const tierLabel = consent.tier !== null && options.catalog
125
+ ? translatedOrUndefined(i18n.t(options.catalog.tierLabelKey(consent.tier), locale), options.catalog.tierLabelKey(consent.tier))
126
+ : undefined;
127
+ const itemLabel = consent.itemLabelKey !== undefined
128
+ ? translatedOrUndefined(i18n.t(consent.itemLabelKey, locale, consent.itemLabelParams), consent.itemLabelKey)
129
+ : undefined;
111
130
  const issuedAtIso = options.now().toString();
112
131
  const content = renderContractConfirmation({
113
132
  consent,
133
+ timeZone: pickTimeZone(recipient.data.timezone, ctx.tz.tenant),
134
+ ...(tierLabel !== undefined && { tierLabel }),
135
+ ...(itemLabel !== undefined && { itemLabel }),
114
136
  consentGivenAtIso: consentEvent.createdAt.toString(),
115
137
  contractStartIso: issuedAtIso,
116
138
  ...(payload.currentPeriodEndIso !== undefined && {
@@ -65,6 +65,8 @@ export declare const checkoutConsentRecordedPayloadSchema: z.ZodObject<{
65
65
  termsTemplateVersion: z.ZodNumber;
66
66
  locale: z.ZodString;
67
67
  actorUserId: z.ZodString;
68
+ itemLabelKey: z.ZodOptional<z.ZodString>;
69
+ itemLabelParams: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>>>;
68
70
  }, z.core.$strip>;
69
71
  export type CheckoutConsentRecordedPayload = z.infer<typeof checkoutConsentRecordedPayloadSchema>;
70
72
  export declare const CONTRACT_CONFIRMATION_ISSUED_EVENT_SHORT: "contract-confirmation-issued";
@@ -101,6 +101,8 @@ export const checkoutConsentRecordedPayloadSchema = z.object({
101
101
  termsTemplateVersion: z.number(),
102
102
  locale: z.string().min(2).max(35),
103
103
  actorUserId: z.string().min(1),
104
+ itemLabelKey: z.string().min(1).max(200).optional(),
105
+ itemLabelParams: z.record(z.string(), z.union([z.string().max(100), z.number()])).optional(),
104
106
  });
105
107
  // =============================================================================
106
108
  // contract-confirmation-issued — § 312f confirmation mail sent
@@ -1,3 +1,3 @@
1
1
  import type { QueryHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
- import type { BillingPlanCatalog, ResolvedBillingFoundationOptions } from "../types.js";
2
+ import { type BillingPlanCatalog, type ResolvedBillingFoundationOptions } from "../types.js";
3
3
  export declare function createBillingPlansQuery(options: ResolvedBillingFoundationOptions, catalog: BillingPlanCatalog): QueryHandlerDef;
@@ -5,6 +5,7 @@ import * as z from "zod";
5
5
  import { findCatalogProvider } from "../checkout-core.js";
6
6
  import { CONSENT_TEXTS, consentTextVersion, } from "../consumer-protection/consent-text.js";
7
7
  import { buildBillingPlans } from "../plan-catalog.js";
8
+ import { isLegalLinkSet, } from "../types.js";
8
9
  const billingPlansSchema = z.object({}).strict();
9
10
  export function createBillingPlansQuery(options, catalog) {
10
11
  return {
@@ -25,12 +26,15 @@ export function createBillingPlansQuery(options, catalog) {
25
26
  de: consentTextView("de"),
26
27
  en: consentTextView("en"),
27
28
  },
28
- legalLinks: consumerProtection.legalLinks,
29
+ legalLinks: legalLinksPerLocale(consumerProtection.legalLinks),
29
30
  },
30
31
  };
31
32
  },
32
33
  };
33
34
  }
35
+ function legalLinksPerLocale(legalLinks) {
36
+ return isLegalLinkSet(legalLinks) ? { de: legalLinks, en: legalLinks } : legalLinks;
37
+ }
34
38
  function consentTextView(locale) {
35
39
  const { earlyPerformance, withdrawalLoss } = CONSENT_TEXTS[locale];
36
40
  return { earlyPerformance, withdrawalLoss, consentTextVersion: consentTextVersion(locale) };
@@ -21,8 +21,9 @@
21
21
  // billing-foundation:write:switch-plan instead. Plan-driven callers should
22
22
  // prefer start-plan-checkout/switch-plan over calling this handler with a
23
23
  // hand-picked priceId.
24
+ import { UnprocessableError } from "@cosmicdrift/kumiko-framework/errors";
24
25
  import * as z from "zod";
25
- import { findProviderPlugin, openCheckout } from "../checkout-core.js";
26
+ import { assertCheckoutAllowed, openCheckout, startProviderCheckout, } from "../checkout-core.js";
26
27
  import { consentCheckoutFields, consentPayloadSchema, prepareConsent, recordConsent, } from "../consumer-protection/consent-gate.js";
27
28
  const createCheckoutSessionSchema = z.object({
28
29
  /** Welcher Provider — entityName eines registrierten subscription-
@@ -44,7 +45,7 @@ const createCheckoutSessionSchema = z.object({
44
45
  export function createCheckoutSessionHandler(options) {
45
46
  const consumerProtection = options.consumerProtection;
46
47
  const schema = consumerProtection
47
- ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() })
48
+ ? createCheckoutSessionSchema.extend({ consent: consentPayloadSchema.optional() }).strict()
48
49
  : createCheckoutSessionSchema;
49
50
  return {
50
51
  name: "create-checkout-session",
@@ -56,27 +57,39 @@ export function createCheckoutSessionHandler(options) {
56
57
  handler: async (event, ctx) => {
57
58
  // @cast-boundary engine-payload — dispatcher-zod-validated payload
58
59
  const payload = event.payload;
59
- const mode = payload.mode ?? "subscription";
60
- const consent = consumerProtection
61
- ? await prepareConsent(ctx, consumerProtection, payload.consent, mode)
62
- : undefined;
63
- const result = await openCheckout(ctx, { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now }, {
60
+ const openOptions = { baseUrl: options.baseUrl, catalog: options.catalog, now: options.now };
61
+ const checkoutInput = {
64
62
  providerName: payload.providerName,
65
63
  priceId: payload.priceId,
66
64
  successUrl: payload.successUrl,
67
65
  cancelUrl: payload.cancelUrl,
68
66
  ...(payload.providerCustomerId && { providerCustomerId: payload.providerCustomerId }),
69
67
  ...(payload.mode && { mode: payload.mode }),
70
- ...(consent && consentCheckoutFields(consent)),
71
- });
72
- if (consent) {
73
- const plugin = findProviderPlugin(ctx, payload.providerName)?.plugin;
74
- await recordConsent(ctx, consent, {
75
- tier: mode === "subscription" ? (plugin?.priceToTier?.[payload.priceId] ?? null) : null,
76
- priceId: payload.priceId,
77
- price: await lookupPrice(ctx, plugin, payload.priceId),
78
- });
68
+ };
69
+ if (!consumerProtection) {
70
+ const result = await openCheckout(ctx, openOptions, checkoutInput);
71
+ return {
72
+ isSuccess: true,
73
+ data: { url: result.url, providerName: result.providerName },
74
+ };
79
75
  }
76
+ const mode = payload.mode ?? "subscription";
77
+ const plugin = await assertCheckoutAllowed(ctx, openOptions, checkoutInput);
78
+ const price = await lookupPrice(ctx, plugin, payload.priceId);
79
+ const orderItem = mode === "payment"
80
+ ? await consumerProtection.oneOffItemLabel?.(ctx, payload.priceId)
81
+ : undefined;
82
+ const consent = await prepareConsent(ctx, consumerProtection, payload.consent, mode);
83
+ const result = await startProviderCheckout(ctx, plugin, {
84
+ ...checkoutInput,
85
+ ...consentCheckoutFields(consent),
86
+ });
87
+ await recordConsent(ctx, consent, {
88
+ tier: mode === "subscription" ? (plugin.priceToTier?.[payload.priceId] ?? null) : null,
89
+ priceId: payload.priceId,
90
+ price,
91
+ ...(orderItem && { orderItem }),
92
+ });
80
93
  return {
81
94
  isSuccess: true,
82
95
  data: { url: result.url, providerName: result.providerName },
@@ -84,16 +97,25 @@ export function createCheckoutSessionHandler(options) {
84
97
  },
85
98
  };
86
99
  }
87
- // A failed price lookup must not lose a consent the buyer already gave at the
88
- // provider, so it degrades to null price fields.
100
+ // The price is part of the consent record, so it must be known before the
101
+ // buyer is sent to the provider.
89
102
  async function lookupPrice(ctx, plugin, priceId) {
90
- if (!plugin?.retrievePrices)
91
- return null;
103
+ let price;
92
104
  try {
93
- const prices = await plugin.retrievePrices(ctx, [priceId]);
94
- return prices.find((p) => p.priceId === priceId) ?? null;
105
+ const prices = (await plugin.retrievePrices?.(ctx, [priceId])) ?? [];
106
+ price = prices.find((p) => p.priceId === priceId);
95
107
  }
96
- catch {
97
- return null;
108
+ catch (cause) {
109
+ throw priceUnavailable(priceId, cause);
98
110
  }
111
+ if (!price)
112
+ throw priceUnavailable(priceId);
113
+ return price;
114
+ }
115
+ function priceUnavailable(priceId, cause) {
116
+ return new UnprocessableError("price_unavailable", {
117
+ i18nKey: "billing-foundation.errors.priceUnavailable",
118
+ message: `billing-foundation: no live price resolved for priceId "${priceId}"`,
119
+ ...(cause instanceof Error && { cause }),
120
+ });
99
121
  }
@@ -2,6 +2,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
2
2
  export { type BillingInfo, type BillingInfoQueryDeps, createBillingInfoQueryConfig, } from "./billing-info-query.js";
3
3
  export { isBillingEnabled, type ResolvedProvider, resolveProviderPlugin, } from "./checkout-core.js";
4
4
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, type BillingEventKind, BillingEventKinds, type BillingPlanAction, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, SubscriptionCancelTimings, type SubscriptionEventType, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "./constants.js";
5
+ export { type ConsentPayload, consentPayloadSchema, type OrderItem, } from "./consumer-protection/consent-gate.js";
6
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
5
7
  export { type ContractTerminationRoutesOptions, createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
6
8
  export { paymentEntity, subscriptionEntity } from "./entities.js";
7
9
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, type PaymentEventHeaders, type PaymentEventPayload, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, type SubscriptionEventHeaders, type SubscriptionEventPayload, subscriptionEventPayloadSchema, } from "./events.js";
@@ -10,5 +12,5 @@ export { getSubscriptionForTenant, type SubscriptionView } from "./get-subscript
10
12
  export { paymentsProjectionTable, subscriptionsProjectionTable } from "./projection.js";
11
13
  export { billingPlansPanel, createBillingPlansScreen } from "./screens.js";
12
14
  export { createSubscriptionTierSync, effectiveTierFromSubscription, SUBSCRIPTION_WEBHOOK_PATH, type SubscriptionTierSyncDeps, } from "./subscription-tier-sync.js";
13
- export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
15
+ export { type BillingFoundationOptions, type BillingPlanBenefit, type BillingPlanCatalog, type BillingPlanPrice, type BillingPlansResult, type BillingPlanView, type ConsumerProtectionOptions, KNOWN_RECURRING_INTERVALS, type LegalLinkSet, type PaymentEvent, type ProviderPrice, type ProviderSubscriptionSnapshot, type RecurringInterval, type SubscriptionEvent, type SubscriptionProviderPlugin, } from "./types.js";
14
16
  export { createSubscriptionWebhookRoute, type SubscriptionWebhookRouteOptions, } from "./webhook-handler.js";
@@ -3,6 +3,8 @@ export { paymentAggregateId, subscriptionAggregateId } from "./aggregate-id.js";
3
3
  export { createBillingInfoQueryConfig, } from "./billing-info-query.js";
4
4
  export { isBillingEnabled, resolveProviderPlugin, } from "./checkout-core.js";
5
5
  export { BILLING_FOUNDATION_FEATURE, BILLING_PLANS_PANEL_COMPONENT, BILLING_PLANS_SCREEN_ID, BillingEventKinds, BillingPlanActions, DEFAULT_PURCHASE_ROLES, isSwitchableSubscriptionStatus, isTerminalSubscriptionStatus, parseProviderConsentId, SUBSCRIPTION_PROVIDER_EXTENSION, SubscriptionCancelTimings, SubscriptionEventTypes, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "./constants.js";
6
+ export { consentPayloadSchema, } from "./consumer-protection/consent-gate.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "./consumer-protection/consent-locale.js";
6
8
  export { createContractTerminationRoutes, } from "./consumer-protection/termination-pages.js";
7
9
  export { paymentEntity, subscriptionEntity } from "./entities.js";
8
10
  export { INVOICE_PAID_EVENT_QN, INVOICE_PAID_EVENT_SHORT, INVOICE_PAYMENT_FAILED_EVENT_QN, INVOICE_PAYMENT_FAILED_EVENT_SHORT, PAYMENT_AGGREGATE_TYPE, PAYMENT_RECEIVED_EVENT_QN, PAYMENT_RECEIVED_EVENT_SHORT, paymentEventPayloadSchema, SUBSCRIPTION_AGGREGATE_TYPE, SUBSCRIPTION_CANCELED_EVENT_QN, SUBSCRIPTION_CANCELED_EVENT_SHORT, SUBSCRIPTION_CREATED_EVENT_QN, SUBSCRIPTION_CREATED_EVENT_SHORT, SUBSCRIPTION_UPDATED_EVENT_QN, SUBSCRIPTION_UPDATED_EVENT_SHORT, subscriptionEventPayloadSchema, } from "./events.js";
@@ -3,6 +3,8 @@ import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
3
3
  import type { HandlerContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
4
  import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
5
5
  import { type BillingEventKinds, type BillingPlanAction, SUBSCRIPTION_PROVIDER_EXTENSION, type SubscriptionCancelTiming, type SubscriptionEventType, type SubscriptionStatus, type TerminationScope } from "./constants.js";
6
+ import type { OrderItem } from "./consumer-protection/consent-gate.js";
7
+ import type { ConsentLocale } from "./consumer-protection/consent-locale.js";
6
8
  export type SubscriptionEvent = {
7
9
  /** Discriminator against PaymentEvent. Optional (instead of required) so
8
10
  * existing plugins like subscription-mollie, which don't set this field,
@@ -279,6 +281,12 @@ export type BillingPlanCatalog<TTier extends string = string> = {
279
281
  /** Defaults to the only registered provider exposing `priceToTier`. */
280
282
  readonly providerName?: string;
281
283
  };
284
+ export type LegalLinkSet = {
285
+ readonly terms: string;
286
+ readonly withdrawal: string;
287
+ readonly privacy: string;
288
+ };
289
+ export declare function isLegalLinkSet(links: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>): links is LegalLinkSet;
282
290
  /** Consumer-protection consent gate (§ 312j / § 356 BGB style). When set,
283
291
  * `start-plan-checkout` and `create-checkout-session` require a `consent`
284
292
  * payload and record it as an event before the buyer reaches the provider. */
@@ -294,12 +302,13 @@ export type ConsumerProtectionOptions = {
294
302
  * host that resolves none (the platform apex) — the declaration then runs
295
303
  * without a request tenant and finds the contract by the declarant's email. */
296
304
  readonly terminationScope?: TerminationScope;
297
- /** Root-relative paths or absolute https URLs. */
298
- readonly legalLinks: {
299
- readonly terms: string;
300
- readonly withdrawal: string;
301
- readonly privacy: string;
302
- };
305
+ /** Names the item of a one-off payment (mode "payment") for the consent record
306
+ * and the confirmation mail; the label key is resolved through the registry
307
+ * i18n, so it must be qualified with the registering feature. */
308
+ readonly oneOffItemLabel?: (ctx: HandlerContext, priceId: string) => Promise<OrderItem | undefined>;
309
+ /** Root-relative paths or absolute https URLs. One set for every locale, or
310
+ * one set per consent locale (`de` and `en` required). */
311
+ readonly legalLinks: LegalLinkSet | Readonly<Record<ConsentLocale, LegalLinkSet>>;
303
312
  };
304
313
  export type BillingFoundationOptions<TTier extends string = string> = {
305
314
  /** Absolute http(s) URL. Its origin is the only redirect origin
@@ -361,7 +370,7 @@ export type BillingPlansResult = {
361
370
  readonly withdrawalLoss: string;
362
371
  readonly consentTextVersion: string;
363
372
  }>>;
364
- readonly legalLinks: ConsumerProtectionOptions["legalLinks"];
373
+ readonly legalLinks: Readonly<Record<ConsentLocale, LegalLinkSet>>;
365
374
  };
366
375
  };
367
376
  declare module "@cosmicdrift/kumiko-framework/engine" {
@@ -30,3 +30,6 @@ import { SUBSCRIPTION_PROVIDER_EXTENSION, } from "./constants.js";
30
30
  // =============================================================================
31
31
  // Closed set a provider's recurring-price interval narrows to; an unrecognized provider value maps to null instead of widening this type.
32
32
  export const KNOWN_RECURRING_INTERVALS = ["day", "week", "month", "year"];
33
+ export function isLegalLinkSet(links) {
34
+ return "terms" in links;
35
+ }
@@ -1,4 +1,4 @@
1
- import type { BillingFoundationOptions } from "./types.js";
1
+ import { type BillingFoundationOptions } from "./types.js";
2
2
  /** Validates `options` and throws a plain `Error` with a clear message at
3
3
  * feature-definition time — same pattern as `createCapOverviewFeature`.
4
4
  * `createBillingFoundationFeature()` (no options) always succeeds; the
@@ -4,6 +4,8 @@
4
4
  // re-exported from index.ts.
5
5
  import * as z from "zod";
6
6
  import { isTerminationScope, TERMINATION_SCOPES } from "./constants.js";
7
+ import { CONSENT_LOCALES } from "./consumer-protection/consent-locale.js";
8
+ import { isLegalLinkSet, } from "./types.js";
7
9
  function isRootRelativePath(path) {
8
10
  return path.startsWith("/") && !path.startsWith("//");
9
11
  }
@@ -29,12 +31,32 @@ function validateConsumerProtection(cp) {
29
31
  if (!z.email().safeParse(cp.operatorEmail).success) {
30
32
  throw new Error(`createBillingFoundationFeature: consumerProtection.operatorEmail "${cp.operatorEmail}" is not a valid email address.`);
31
33
  }
34
+ if (cp.oneOffItemLabel !== undefined && typeof cp.oneOffItemLabel !== "function") {
35
+ throw new Error("createBillingFoundationFeature: consumerProtection.oneOffItemLabel must be a function.");
36
+ }
32
37
  if (cp.terminationScope !== undefined && !isTerminationScope(cp.terminationScope)) {
33
38
  throw new Error(`createBillingFoundationFeature: consumerProtection.terminationScope "${String(cp.terminationScope)}" must be one of ${TERMINATION_SCOPES.map((scope) => `"${scope}"`).join(", ")}.`);
34
39
  }
35
- for (const [key, link] of Object.entries(cp.legalLinks)) {
40
+ if (isLegalLinkSet(cp.legalLinks)) {
41
+ validateLegalLinkSet(cp.legalLinks, "legalLinks");
42
+ }
43
+ else {
44
+ validatePerLocaleLegalLinks(cp.legalLinks);
45
+ }
46
+ }
47
+ function validatePerLocaleLegalLinks(legalLinks) {
48
+ for (const locale of CONSENT_LOCALES) {
49
+ const set = legalLinks[locale];
50
+ if (!set) {
51
+ throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks needs a "${locale}" entry when given per locale.`);
52
+ }
53
+ validateLegalLinkSet(set, `legalLinks.${locale}`);
54
+ }
55
+ }
56
+ function validateLegalLinkSet(set, path) {
57
+ for (const [key, link] of Object.entries(set)) {
36
58
  if (!isRootRelativeOrHttpsUrl(link)) {
37
- throw new Error(`createBillingFoundationFeature: consumerProtection.legalLinks.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
59
+ throw new Error(`createBillingFoundationFeature: consumerProtection.${path}.${key} "${link}" must be a root-relative path ("/..." not "//") or an absolute https URL.`);
38
60
  }
39
61
  }
40
62
  }
@@ -189,7 +189,7 @@ export function BillingPlansPanel(_props) {
189
189
  date: toInstant(result.subscription.cancelAt).toLocaleString(locale, {
190
190
  dateStyle: "medium",
191
191
  }),
192
- }), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
192
+ }), " ", t("billing-foundation.plans.switchRequiresReactivation")] })), consumerProtection !== undefined && canManage && result.subscription?.cancelAt == null && (_jsx("div", { children: _jsx(Button, { variant: "secondary", onClick: () => setCancelOpen(true), testId: "billing-plans-cancel-contract", children: t("billing-foundation.cancel.open") }) })), _jsxs(PlanGrid, { testId: "billing-plans-grid", children: [!currentTierInPlans && (_jsx(PlanCard, { title: t(result.currentTier.labelKey), current: true, testId: "billing-plan-card-current", features: result.currentTier.benefits.map((benefit) => t(benefit.labelKey, localizedParams(benefit.params, locale))), secondaryAction: manageOrReactivateAction(canManage, result.subscription, t, handleManage, redirecting) })), visiblePlans.map((plan) => (_jsx(PlanCard, { testId: `billing-plan-card-${plan.tier}`, ...toCardProps(plan) }, plan.tier)))] }), consumerProtection !== undefined &&
193
193
  consentPlan !== undefined &&
194
194
  consentPlan.price !== null && (_jsx(CheckoutConsentDialog, { open: true, onOpenChange: (open) => {
195
195
  if (!open)
@@ -14,7 +14,7 @@ const TERMINATION_KINDS = ["ordinary", "extraordinary"];
14
14
  export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
15
15
  const t = useTranslation();
16
16
  const locale = useLocale().locale();
17
- const { Modal, Heading, Text, Field, Input, Button, Banner } = usePrimitives();
17
+ const { Modal, Text, Field, Input, Button, Banner } = usePrimitives();
18
18
  const terminate = useMutation(SubscriptionFoundationHandlers.terminateContract);
19
19
  const [step, setStep] = useState("form");
20
20
  const [declarationType, setDeclarationType] = useState("termination");
@@ -38,7 +38,7 @@ export function CancelContractDialog({ open, onOpenChange, onTerminated, }) {
38
38
  function formatInstant(iso) {
39
39
  return toInstant(iso).toLocaleString(locale, { dateStyle: "medium", timeStyle: "short" });
40
40
  }
41
- return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.cancel.title"), testId: "cancel-contract-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.cancel.title") }), step === "form" && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.declaration") }), _jsx(ModeSwitch, { value: declarationType, options: DECLARATION_TYPES.map((value) => ({
41
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.cancel.title"), testId: "cancel-contract-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [step === "form" && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.declaration") }), _jsx(ModeSwitch, { value: declarationType, options: DECLARATION_TYPES.map((value) => ({
42
42
  value,
43
43
  label: t(`billing-foundation.cancel.declaration.${value}`),
44
44
  })), onChange: setDeclarationType, ariaLabel: t("billing-foundation.cancel.declaration"), testId: "cancel-declaration-type" }), declarationType === "withdrawal" && (_jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.withdrawalHint") })), _jsx(Text, { variant: "muted", children: t("billing-foundation.cancel.kind") }), _jsx(ModeSwitch, { value: terminationKind, options: TERMINATION_KINDS.map((value) => ({
@@ -16,7 +16,7 @@ export type CheckoutConsentDialogProps = {
16
16
  readonly price: {
17
17
  readonly amount: string;
18
18
  readonly renewalKey?: string;
19
- readonly count: string;
19
+ readonly count?: string;
20
20
  };
21
21
  readonly consumerProtection: ConsumerProtectionView;
22
22
  readonly onOrder: (consent: CheckoutConsentPayload) => Promise<WriteResult<{
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
3
3
  import { useState } from "react";
4
4
  import { resolveConsentLocale } from "../consumer-protection/consent-locale.js";
@@ -7,14 +7,14 @@ export const CONSENT_TEXT_OUTDATED_CODE = "consent_text_outdated";
7
7
  export function CheckoutConsentDialog({ open, onOpenChange, planName, price, consumerProtection, onOrder, onConsentTextOutdated, }) {
8
8
  const t = useTranslation();
9
9
  const uiLocale = useLocale().locale();
10
- const { Modal, Heading, Text, Field, Input, Button, Link, Banner } = usePrimitives();
10
+ const { Modal, Text, Field, Input, Button, Link, Banner } = usePrimitives();
11
11
  const [earlyPerformance, setEarlyPerformance] = useState(false);
12
12
  const [withdrawalLoss, setWithdrawalLoss] = useState(false);
13
13
  const [submitting, setSubmitting] = useState(false);
14
14
  const [errorText, setErrorText] = useState(null);
15
15
  const consentLocale = resolveConsentLocale(uiLocale);
16
16
  const texts = consumerProtection.consentTexts[consentLocale];
17
- const { legalLinks } = consumerProtection;
17
+ const legalLinks = consumerProtection.legalLinks[consentLocale];
18
18
  async function order() {
19
19
  setSubmitting(true);
20
20
  setErrorText(null);
@@ -34,5 +34,5 @@ export function CheckoutConsentDialog({ open, onOpenChange, planName, price, con
34
34
  onConsentTextOutdated();
35
35
  }
36
36
  }
37
- return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsx(Heading, { variant: "page", children: t("billing-foundation.consent.title") }), _jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsx(Text, { variant: "muted", children: t(price.renewalKey, { count: price.count }) })), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }), _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") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
37
+ return (_jsx(Modal, { open: open, onOpenChange: onOpenChange, title: t("billing-foundation.consent.title"), testId: "checkout-consent-dialog", children: _jsxs("div", { className: "flex flex-col gap-4", children: [_jsxs("div", { className: "flex flex-col gap-1", "data-testid": "checkout-consent-summary", children: [_jsxs(Text, { children: [planName, " \u2014 ", price.amount] }), price.renewalKey !== undefined && (_jsxs(_Fragment, { children: [_jsx(Text, { variant: "muted", children: t(price.renewalKey, price.count !== undefined ? { count: price.count } : undefined) }), _jsx(Text, { variant: "muted", children: t("billing-foundation.consent.cancelAnytime") })] }))] }), _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") })] }), _jsx(Field, { id: "consent-early-performance", label: texts.earlyPerformance, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-early-performance", name: "consent-early-performance", value: earlyPerformance, onChange: setEarlyPerformance, disabled: submitting }) }), _jsx(Field, { id: "consent-withdrawal-loss", label: texts.withdrawalLoss, layout: "inline", children: _jsx(Input, { kind: "boolean", id: "consent-withdrawal-loss", name: "consent-withdrawal-loss", value: withdrawalLoss, onChange: setWithdrawalLoss, disabled: submitting }) }), errorText !== null && (_jsx(Banner, { variant: "error", testId: "checkout-consent-error", children: errorText })), _jsxs("div", { className: "flex justify-end gap-2", children: [_jsx(Button, { variant: "secondary", disabled: submitting, onClick: () => onOpenChange(false), testId: "checkout-consent-cancel", children: t("billing-foundation.consent.back") }), _jsx(Button, { disabled: !earlyPerformance || !withdrawalLoss || submitting, loading: submitting, onClick: order, testId: "checkout-consent-order", children: t("billing-foundation.consent.order") })] })] }) }));
38
38
  }
@@ -1,4 +1,6 @@
1
1
  export { BILLING_PLANS_SCREEN_ID, type BillingPlanAction, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, type SubscriptionStatus, SubscriptionStatuses, } from "../constants.js";
2
- export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, } from "../types.js";
2
+ export { CONSENT_LOCALES, type ConsentLocale, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
3
+ export type { BillingPlanBenefit, BillingPlanCatalog, BillingPlansResult, BillingPlanView, LegalLinkSet, } from "../types.js";
3
4
  export { BillingPlansPanel } from "./billing-plans-panel.js";
5
+ export { CheckoutConsentDialog, type CheckoutConsentDialogProps, type CheckoutConsentPayload, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
4
6
  export { type BillingFoundationClientOptions, billingFoundationClient } from "./client-plugin.js";
@@ -4,5 +4,7 @@
4
4
  // the server side (createBillingFoundationFeature) lives under
5
5
  // `@cosmicdrift/kumiko-bundled-features/billing-foundation` and has no React deps.
6
6
  export { BILLING_PLANS_SCREEN_ID, BillingPlanActions, SubscriptionFoundationHandlers, SubscriptionFoundationQueries, SubscriptionStatuses, } from "../constants.js";
7
+ export { CONSENT_LOCALES, resolveConsentLocale, } from "../consumer-protection/consent-locale.js";
7
8
  export { BillingPlansPanel } from "./billing-plans-panel.js";
9
+ export { CheckoutConsentDialog, CONSENT_TEXT_OUTDATED_CODE, } from "./checkout-consent-dialog.js";
8
10
  export { billingFoundationClient } from "./client-plugin.js";
@@ -4,6 +4,8 @@ export type LegalPageRoute = {
4
4
  readonly slug: string;
5
5
  readonly lang: string;
6
6
  readonly titleFallback: string;
7
+ /** Prepend the page title as `<h1>` to the rendered body. Default false. */
8
+ readonly titleHeading?: boolean;
7
9
  };
8
10
  export type LegalRequiredBlock = {
9
11
  readonly slug: string;
@@ -1,5 +1,6 @@
1
1
  import { computeRevisionEtag, etagMatches } from "@cosmicdrift/kumiko-framework/api";
2
2
  import { defineFeature, SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { escapeHtml } from "@cosmicdrift/kumiko-headless";
3
4
  import { cachedSecurePageResponse, redirectToCanonicalPath, slashVariantOf, } from "../page-render/index.js";
4
5
  import { LEGAL_REQUIRED_BLOCKS, LEGAL_ROUTES, } from "./constants.js";
5
6
  import { runLegalPagesBootCheck } from "./lib/boot-check.js";
@@ -85,9 +86,13 @@ export function createLegalPagesFeature(opts = {}) {
85
86
  extra,
86
87
  });
87
88
  }
89
+ const title = data.title || route.titleFallback;
90
+ const renderedBody = renderMarkdownToHtml(data.content);
88
91
  const html = wrapLayout({
89
- title: data.title || route.titleFallback,
90
- bodyHtml: renderMarkdownToHtml(data.content),
92
+ title,
93
+ bodyHtml: route.titleHeading
94
+ ? `<h1>${escapeHtml(title)}</h1>${renderedBody}`
95
+ : renderedBody,
91
96
  lang: route.lang,
92
97
  slug: route.slug,
93
98
  });
@@ -1,4 +1,5 @@
1
1
  import { escapeHtml, escapeHtmlAttr } from "@cosmicdrift/kumiko-headless";
2
+ import { renderSafeMarkdown } from "../page-render/index.js";
2
3
  const DEFAULT_PRIMARY_COLOR = "#2563eb";
3
4
  const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
4
5
  function isHttpUrl(value) {
@@ -95,6 +96,13 @@ function renderSection(section, primaryColor) {
95
96
  if ("text" in section) {
96
97
  return `<p style="margin:0 0 16px;color:#333;font-size:14px;line-height:1.5">${escapeHtml(section.text)}</p>`;
97
98
  }
99
+ if ("heading" in section) {
100
+ return `<h2 style="margin:24px 0 12px;color:#111;font-size:16px;font-weight:600">${escapeHtml(section.heading)}</h2>`;
101
+ }
102
+ if ("markdown" in section) {
103
+ const markdownHtml = renderSafeMarkdown(section.markdown);
104
+ return `<div style="color:#333;font-size:14px;line-height:1.5">${markdownHtml}</div>`;
105
+ }
98
106
  if ("button" in section) {
99
107
  return `<p style="margin:0 0 16px"><a href="${escapeHtml(section.button.url)}" style="display:inline-block;padding:10px 24px;background:${escapeHtmlAttr(primaryColor)};color:#fff;text-decoration:none;border-radius:4px;font-size:14px">${escapeHtml(section.button.label)}</a></p>`;
100
108
  }
@@ -144,7 +144,7 @@ function buildDispatchSpec(payload, fields) {
144
144
  }
145
145
  export function createStepDispatcherFeature() {
146
146
  return defineFeature("step-dispatcher", (r) => {
147
- r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`.");
147
+ r.describe("Internal system feature that drains deferred Tier-2 side-effects (currently `webhook.send` and `mail.send`) after their originating transaction commits. Listens via `r.multiStreamProjection` on the `kumiko:system:step.dispatch-requested` system event, performs the actual HTTP or mail delivery, then appends `kumiko:system:step.dispatched` or `kumiko:system:step.dispatch-failed` back onto the same stream so the outcome is recorded in the event log without a separate status table. Mount this feature explicitly via `createStepDispatcherFeature()` in your app's feature list alongside any features that use `r.step.webhook.send` or `r.step.mail.send`. Requires the `secrets` feature (`createSecretsFeature()`) to be mounted — `webhook.send` auth resolves per-tenant through it, under `step-dispatcher:webhook-auth.<name>`. Every `webhook.send` request carries `Idempotency-Key: <dispatch stream id>`, stable across redeliveries of the same dispatch request; an explicit `Idempotency-Key` in `headers` takes precedence.");
148
148
  r.secretNamespace("webhook-auth", WEBHOOK_AUTH_SECRET_NAMESPACE_OPTIONS);
149
149
  r.uiHints({
150
150
  displayLabel: "Step Dispatcher · Deferred Side-Effects",
@@ -206,6 +206,7 @@ export function createStepDispatcherFeature() {
206
206
  tenantId: event.tenantId,
207
207
  userId: event.metadata.userId || SYSTEM_USER_ID,
208
208
  secrets: ctx.secrets,
209
+ idempotencyKey: event.aggregateId,
209
210
  })
210
211
  : await performMailDispatch(dispatchSpec.spec);
211
212
  if (result.ok) {
@@ -45,5 +45,7 @@ export type WebhookDispatchDeps = {
45
45
  readonly tenantId: TenantId;
46
46
  readonly userId: string;
47
47
  readonly secrets: SecretsContext | undefined;
48
+ readonly idempotencyKey: string;
48
49
  };
50
+ export declare const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
49
51
  export declare function performWebhookDispatch(spec: WebhookSpec, deps: WebhookDispatchDeps): Promise<WebhookDispatchResult>;
@@ -83,8 +83,15 @@ export function setWebhookFetch(fn) {
83
83
  // tenant-chosen name, but the error still reaches the tenant via the
84
84
  // dispatch-failed event, so it stays generic.
85
85
  const WEBHOOK_AUTH_SECRET_UNAVAILABLE_ERROR = "webhook auth secret is not available";
86
+ export const WEBHOOK_IDEMPOTENCY_KEY_HEADER = "idempotency-key";
87
+ function hasIdempotencyKeyHeader(headers) {
88
+ return Object.keys(headers).some((name) => name.toLowerCase() === WEBHOOK_IDEMPOTENCY_KEY_HEADER);
89
+ }
86
90
  async function buildWebhookHeaders(spec, deps) {
87
91
  const headers = { "content-type": "application/json", ...spec.headers };
92
+ if (!hasIdempotencyKeyHeader(headers)) {
93
+ headers[WEBHOOK_IDEMPOTENCY_KEY_HEADER] = deps.idempotencyKey;
94
+ }
88
95
  if (!spec.auth)
89
96
  return { ok: true, headers };
90
97
  if (!deps.secrets)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.343.0",
3
+ "version": "0.345.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -503,12 +503,12 @@
503
503
  }
504
504
  },
505
505
  "dependencies": {
506
- "@cosmicdrift/kumiko-dispatcher-live": "0.343.0",
507
- "@cosmicdrift/kumiko-framework": "0.343.0",
508
- "@cosmicdrift/kumiko-headless": "0.343.0",
509
- "@cosmicdrift/kumiko-renderer": "0.343.0",
510
- "@cosmicdrift/kumiko-renderer-web": "0.343.0",
511
- "@cosmicdrift/kumiko-types": "0.343.0",
506
+ "@cosmicdrift/kumiko-dispatcher-live": "0.345.0",
507
+ "@cosmicdrift/kumiko-framework": "0.345.0",
508
+ "@cosmicdrift/kumiko-headless": "0.345.0",
509
+ "@cosmicdrift/kumiko-renderer": "0.345.0",
510
+ "@cosmicdrift/kumiko-renderer-web": "0.345.0",
511
+ "@cosmicdrift/kumiko-types": "0.345.0",
512
512
  "@mollie/api-client": "^4.5.0",
513
513
  "@node-rs/argon2": "^2.0.2",
514
514
  "@types/mailparser": "^3.4.6",
@@ -1025,8 +1025,8 @@
1025
1025
  ],
1026
1026
  "devDependencies": {
1027
1027
  "@testing-library/user-event": "^14.6.1",
1028
- "@cosmicdrift/kumiko-locale-de": "0.343.0",
1029
- "@cosmicdrift/kumiko-locale-es": "0.343.0",
1028
+ "@cosmicdrift/kumiko-locale-de": "0.345.0",
1029
+ "@cosmicdrift/kumiko-locale-es": "0.345.0",
1030
1030
  "jsqr": "^1.4.0"
1031
1031
  }
1032
1032
  }