@cosmicdrift/kumiko-samples 0.387.0 → 0.388.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 (173) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +8 -1
  3. package/packages/bundled-features/src/agent-tools/changes.json +6 -0
  4. package/packages/bundled-features/src/agent-tools/tool-dispatch.ts +9 -9
  5. package/packages/bundled-features/src/audit/changes.json +12 -0
  6. package/packages/bundled-features/src/audit/handlers/audit-events.ts +1 -1
  7. package/packages/bundled-features/src/auth-mfa/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-mfa/feature-exports.ts +2 -2
  9. package/packages/bundled-features/src/auth-mfa/feature.ts +6 -0
  10. package/packages/bundled-features/src/auth-mfa/web/i18n.ts +6 -1
  11. package/packages/bundled-features/src/auth-mfa/web/mfa-code-or-recovery-field.tsx +68 -0
  12. package/packages/bundled-features/src/auth-mfa/web/mfa-disable-dialog.tsx +23 -13
  13. package/packages/bundled-features/src/auth-mfa/web/mfa-regenerate-recovery-dialog.tsx +23 -13
  14. package/packages/bundled-features/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +15 -6
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-verify-screen.tsx +65 -18
  16. package/packages/bundled-features/src/billing-foundation/changes.json +55 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +32 -42
  18. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +8 -2
  19. package/packages/bundled-features/src/billing-foundation/consumer-protection/consent-gate.ts +2 -0
  20. package/packages/bundled-features/src/billing-foundation/consumer-protection/issue-confirmation.ts +14 -3
  21. package/packages/bundled-features/src/billing-foundation/consumer-protection/mail-notifications.ts +3 -4
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-receipt-limit.ts +12 -6
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +35 -19
  24. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-request.ts +12 -8
  25. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-revoke.ts +14 -8
  26. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-texts.ts +5 -0
  27. package/packages/bundled-features/src/billing-foundation/consumer-protection/withdrawal-period.ts +8 -10
  28. package/packages/bundled-features/src/billing-foundation/events.ts +1 -0
  29. package/packages/bundled-features/src/billing-foundation/feature.ts +3 -1
  30. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +71 -6
  31. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +1 -0
  32. package/packages/bundled-features/src/billing-foundation/handlers/plan-switch-preview.query.ts +65 -0
  33. package/packages/bundled-features/src/billing-foundation/handlers/process-event.write.ts +9 -2
  34. package/packages/bundled-features/src/billing-foundation/handlers/start-plan-checkout.write.ts +44 -8
  35. package/packages/bundled-features/src/billing-foundation/handlers/switch-plan.write.ts +72 -82
  36. package/packages/bundled-features/src/billing-foundation/handlers/sync-subscription.write.ts +14 -1
  37. package/packages/bundled-features/src/billing-foundation/i18n.ts +22 -0
  38. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  39. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +62 -27
  40. package/packages/bundled-features/src/billing-foundation/plan-switch.ts +84 -0
  41. package/packages/bundled-features/src/billing-foundation/types.ts +80 -1
  42. package/packages/bundled-features/src/billing-foundation/validate-options.ts +19 -0
  43. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +119 -47
  44. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +89 -42
  45. package/packages/bundled-features/src/billing-foundation/web/constants.ts +4 -0
  46. package/packages/bundled-features/src/billing-foundation/web/plan-price.ts +55 -0
  47. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +224 -0
  48. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +5 -1
  49. package/packages/bundled-features/src/cap-counter/changes.json +18 -0
  50. package/packages/bundled-features/src/cap-counter/enforce-cap.ts +2 -1
  51. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +8 -1
  52. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  53. package/packages/bundled-features/src/channel-email/feature.ts +1 -1
  54. package/packages/bundled-features/src/config/changes.json +12 -0
  55. package/packages/bundled-features/src/config/handlers/cascade.query.ts +4 -2
  56. package/packages/bundled-features/src/config/handlers/values.query.ts +4 -2
  57. package/packages/bundled-features/src/config/write-helpers.ts +27 -8
  58. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  59. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +10 -9
  60. package/packages/bundled-features/src/delivery/changes.json +30 -0
  61. package/packages/bundled-features/src/delivery/channel-context.ts +9 -3
  62. package/packages/bundled-features/src/delivery/db/queries/preferences.ts +2 -2
  63. package/packages/bundled-features/src/delivery/delivery-service.ts +35 -5
  64. package/packages/bundled-features/src/delivery/handlers/preferences.query.ts +20 -1
  65. package/packages/bundled-features/src/delivery/handlers/unsubscribe-page-chrome.query.ts +1 -1
  66. package/packages/bundled-features/src/delivery/unsubscribe-link.ts +3 -1
  67. package/packages/bundled-features/src/delivery/unsubscribe-page-strings.ts +1 -0
  68. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +6 -0
  69. package/packages/bundled-features/src/delivery/unsubscribe.ts +36 -15
  70. package/packages/bundled-features/src/file-foundation/index.ts +1 -0
  71. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +7 -1
  72. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +0 -2
  73. package/packages/bundled-features/src/files/README.md +1 -1
  74. package/packages/bundled-features/src/form-draft/changes.json +6 -0
  75. package/packages/bundled-features/src/form-draft/db/queries/cleanup.ts +2 -2
  76. package/packages/bundled-features/src/form-draft/decrypt-draft.ts +4 -1
  77. package/packages/bundled-features/src/host-routing/README.md +1 -1
  78. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  79. package/packages/bundled-features/src/host-routing/dispatch.ts +16 -17
  80. package/packages/bundled-features/src/host-routing/options.ts +2 -1
  81. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +18 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +17 -5
  83. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +0 -1
  84. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +0 -3
  85. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +4 -3
  86. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +32 -16
  87. package/packages/bundled-features/src/jobs/changes.json +13 -0
  88. package/packages/bundled-features/src/jobs/feature.ts +14 -4
  89. package/packages/bundled-features/src/jobs/handlers/tenant-failures.query.ts +45 -40
  90. package/packages/bundled-features/src/jobs/handlers/tenant-runs.query.ts +63 -59
  91. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -4
  92. package/packages/bundled-features/src/legal-pages/README.md +1 -1
  93. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  94. package/packages/bundled-features/src/mail-foundation/feature.ts +5 -3
  95. package/packages/bundled-features/src/personal-access-tokens/feature.ts +4 -1
  96. package/packages/bundled-features/src/renderer-pdf/changes.json +6 -0
  97. package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +27 -10
  98. package/packages/bundled-features/src/sessions/feature.ts +7 -2
  99. package/packages/bundled-features/src/shared/decrypt-stored-pii.ts +10 -0
  100. package/packages/bundled-features/src/shared/index.ts +1 -1
  101. package/packages/bundled-features/src/shared/mail-template-body.ts +10 -3
  102. package/packages/bundled-features/src/step-dispatcher/changes.json +6 -0
  103. package/packages/bundled-features/src/step-dispatcher/feature.ts +1 -0
  104. package/packages/bundled-features/src/subscription-dodo/changes.json +14 -0
  105. package/packages/bundled-features/src/subscription-dodo/client.ts +45 -0
  106. package/packages/bundled-features/src/subscription-dodo/constants.ts +24 -0
  107. package/packages/bundled-features/src/subscription-dodo/feature.ts +207 -0
  108. package/packages/bundled-features/src/subscription-dodo/index.ts +16 -0
  109. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +345 -0
  110. package/packages/bundled-features/src/subscription-dodo/runtime.ts +126 -0
  111. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +262 -0
  112. package/packages/bundled-features/src/subscription-stripe/changes.json +12 -0
  113. package/packages/bundled-features/src/subscription-stripe/feature.ts +1 -0
  114. package/packages/bundled-features/src/subscription-stripe/runtime.ts +7 -6
  115. package/packages/bundled-features/src/template-resolver/api.ts +31 -4
  116. package/packages/bundled-features/src/template-resolver/changes.json +48 -0
  117. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +1 -0
  118. package/packages/bundled-features/src/template-resolver/feature.ts +2 -2
  119. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +3 -2
  120. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +27 -5
  121. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +3 -2
  122. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +3 -2
  123. package/packages/bundled-features/src/template-resolver/handlers/find-by-id.query.ts +3 -2
  124. package/packages/bundled-features/src/template-resolver/handlers/list.query.ts +18 -19
  125. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +15 -5
  126. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-counts.query.ts +9 -5
  127. package/packages/bundled-features/src/template-resolver/handlers/tenant-override-selection.ts +8 -6
  128. package/packages/bundled-features/src/template-resolver/handlers/tenant-overrides.query.ts +44 -5
  129. package/packages/bundled-features/src/template-resolver/handlers/usage.query.ts +37 -26
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +7 -2
  131. package/packages/bundled-features/src/template-resolver/seeding.ts +32 -14
  132. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +22 -4
  133. package/packages/bundled-features/src/template-resolver/template-delivery-policy.ts +0 -11
  134. package/packages/bundled-features/src/template-resolver/template-meta.ts +12 -4
  135. package/packages/bundled-features/src/template-resolver/template-preview.ts +11 -3
  136. package/packages/bundled-features/src/template-resolver/web/constants.ts +7 -5
  137. package/packages/bundled-features/src/template-resolver/web/index.ts +7 -0
  138. package/packages/bundled-features/src/template-resolver/web/mail-layout-constants.ts +8 -0
  139. package/packages/bundled-features/src/tenant/changes.json +6 -0
  140. package/packages/bundled-features/src/tenant/handlers/member-directory.query.ts +9 -3
  141. package/packages/bundled-features/src/tenant/handlers/tenant-directory.query.ts +7 -4
  142. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  143. package/packages/bundled-features/src/tenant-lifecycle/constants.ts +5 -0
  144. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +5 -1
  145. package/packages/bundled-features/src/tier-engine/changes.json +6 -0
  146. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +4 -1
  147. package/packages/bundled-features/src/tier-engine/web/constants.ts +3 -3
  148. package/packages/bundled-features/src/user-data-rights/run-export-jobs.ts +3 -4
  149. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +2 -1
  150. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  151. package/packages/bundled-features/src/waitlist/handlers/export-purge.write.ts +2 -1
  152. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +5 -5
  153. package/packages/bundled-features/src/waitlist/import.ts +5 -0
  154. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +22 -3
  155. package/packages/bundled-features/src/waitlist/link-on-signup.ts +20 -10
  156. package/packages/bundled-features/src/waitlist/options.ts +3 -0
  157. package/samples/apps/styleguide/src/features/widgets/i18n.ts +11 -0
  158. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +24 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +5 -0
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +90 -15
  161. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +16 -6
  162. package/samples/apps/use-all-bundled/feature-manifest.json +120 -3
  163. package/samples/apps/use-all-bundled/src/run-config.ts +9 -3
  164. package/samples/recipes/auth-mfa/README.md +5 -0
  165. package/samples/recipes/auth-mfa/package.json +5 -2
  166. package/samples/recipes/auth-mfa/src/web/confirmation-code-step.tsx +49 -0
  167. package/samples/recipes/auth-mfa/tsconfig.json +8 -0
  168. package/samples/recipes/env-composition/README.md +3 -1
  169. package/samples/recipes/env-composition/src/env.ts +2 -0
  170. package/samples/recipes/legal-pages/README.md +1 -1
  171. package/samples/recipes/rate-limiting/README.md +18 -0
  172. package/samples/recipes/rate-limiting/src/feature.ts +12 -0
  173. package/packages/bundled-features/src/billing-foundation/consumer-protection/stored-user-email.ts +0 -12
@@ -16,9 +16,12 @@ import {
16
16
  createChannelEmailFeature,
17
17
  createInMemoryTransport,
18
18
  } from "@cosmicdrift/kumiko-bundled-features/channel-email";
19
+ import type { ExtraRouteDefinition } from "@cosmicdrift/kumiko-framework/api";
19
20
  import type { TenantDb } from "@cosmicdrift/kumiko-framework/db";
20
21
  import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
21
22
  import {
23
+ GALLERY_MOR_PRICE_IDS,
24
+ GALLERY_MOR_PROVIDER,
22
25
  GALLERY_OPERATOR_EMAIL,
23
26
  GALLERY_PLAN_PATH,
24
27
  GALLERY_PRICE_IDS,
@@ -28,25 +31,44 @@ import {
28
31
 
29
32
  export const galleryMailTransport = createInMemoryTransport();
30
33
 
31
- const PRICES: readonly ProviderPrice[] = [
32
- {
33
- priceId: GALLERY_PRICE_IDS.starter,
34
- unitAmount: 900,
35
- currency: "eur",
36
- interval: "month",
37
- intervalCount: 1,
38
- active: true,
39
- metadata: {},
40
- },
41
- {
42
- priceId: GALLERY_PRICE_IDS.pro,
43
- unitAmount: 1900,
34
+ // The merchant-of-record mock is only offered to tenants the spec opts in, so
35
+ // the single-provider flows keep their original screens.
36
+ const morEnabledTenants = new Set<string>();
37
+
38
+ export const GALLERY_MOR_ENABLE_PATH = "/gallery/mor/:tenantId";
39
+
40
+ export function createGalleryMorEnableRoute(): ExtraRouteDefinition {
41
+ return {
42
+ method: "POST",
43
+ path: GALLERY_MOR_ENABLE_PATH,
44
+ entry: "anonymous",
45
+ handler: (c) => {
46
+ morEnabledTenants.add(c.req.param("tenantId") ?? "");
47
+ return c.body(null, 204);
48
+ },
49
+ };
50
+ }
51
+
52
+ function monthlyPrice(priceId: string, unitAmount: number): ProviderPrice {
53
+ return {
54
+ priceId,
55
+ unitAmount,
44
56
  currency: "eur",
45
57
  interval: "month",
46
58
  intervalCount: 1,
47
59
  active: true,
48
60
  metadata: {},
49
- },
61
+ };
62
+ }
63
+
64
+ const PRICES: readonly ProviderPrice[] = [
65
+ monthlyPrice(GALLERY_PRICE_IDS.starter, 900),
66
+ monthlyPrice(GALLERY_PRICE_IDS.pro, 1900),
67
+ ];
68
+
69
+ const MOR_PRICES: readonly ProviderPrice[] = [
70
+ monthlyPrice(GALLERY_MOR_PRICE_IDS.starter, 990),
71
+ monthlyPrice(GALLERY_MOR_PRICE_IDS.pro, 1990),
50
72
  ];
51
73
 
52
74
  async function resolveCurrentTier(db: TenantDb, tenantId: string): Promise<string> {
@@ -74,7 +96,6 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
74
96
  viewRoles: ["TenantAdmin", "SystemAdmin"],
75
97
  successPath: GALLERY_PLAN_PATH,
76
98
  cancelPath: GALLERY_PLAN_PATH,
77
- providerName: GALLERY_PROVIDER,
78
99
  contact: {
79
100
  labelKey: "gallery-screens:plan.enterprise.label",
80
101
  benefits: [{ labelKey: "gallery-screens:plan.enterprise.benefit" }],
@@ -85,6 +106,24 @@ export function createGalleryBillingFeature(baseUrl: string): FeatureDefinition
85
106
  consumerProtection: {
86
107
  termsTextBlock: GALLERY_TERMS_SLUG,
87
108
  vatNote: { de: "Preise inkl. USt.", en: "Prices include VAT." },
109
+ providerTexts: {
110
+ [GALLERY_PROVIDER]: {
111
+ checkoutNote: {
112
+ de: "Rechnung von Gallery Beispiel GmbH",
113
+ en: "Invoiced by Gallery Beispiel GmbH",
114
+ },
115
+ },
116
+ [GALLERY_MOR_PROVIDER]: {
117
+ checkoutNote: {
118
+ de: "Verkäufer ist Dodo Payments · Rechnung und USt von Dodo Payments",
119
+ en: "Sold by Dodo Payments · invoice and VAT from Dodo Payments",
120
+ },
121
+ vatNote: {
122
+ de: "Rechnung und Umsatzsteuer stellt Dodo Payments als Merchant of Record.",
123
+ en: "Dodo Payments, as merchant of record, issues the invoice and charges the VAT.",
124
+ },
125
+ },
126
+ },
88
127
  operatorEmail: GALLERY_OPERATOR_EMAIL,
89
128
  terminationScope: "platform",
90
129
  legalLinks: {
@@ -105,6 +144,7 @@ export function createGalleryEmailFeature(): FeatureDefinition {
105
144
  // The page URL carries the consent id so the spec can echo it on the webhook,
106
145
  // exactly as a real provider echoes the checkout metadata.
107
146
  const galleryProviderPlugin: SubscriptionProviderPlugin = {
147
+ displayName: "Stripe",
108
148
  verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody),
109
149
  priceToTier: {
110
150
  [GALLERY_PRICE_IDS.starter]: "starter",
@@ -119,12 +159,47 @@ const galleryProviderPlugin: SubscriptionProviderPlugin = {
119
159
  cancelSubscription: async () => {},
120
160
  };
121
161
 
162
+ // Merchant-of-record stand-in: switches plans in the app with fixed figures
163
+ // (Starter to Pro: 6.67 EUR due now, 1.06 EUR of it VAT). Applying without a
164
+ // consent id fails, so a successful switch in the spec proves the consent went
165
+ // through.
166
+ const galleryMorProviderPlugin: SubscriptionProviderPlugin = {
167
+ displayName: "Dodo Payments",
168
+ isBillingEnabled: async (ctx) => morEnabledTenants.has(ctx.user.tenantId),
169
+ verifyAndParseWebhook: async (rawBody) => JSON.parse(rawBody),
170
+ priceToTier: {
171
+ [GALLERY_MOR_PRICE_IDS.starter]: "starter",
172
+ [GALLERY_MOR_PRICE_IDS.pro]: "pro",
173
+ },
174
+ retrievePrices: async (_ctx, priceIds) => MOR_PRICES.filter((p) => priceIds.includes(p.priceId)),
175
+ createCheckoutSession: async (_ctx, options) => {
176
+ const url = new URL(options.successUrl);
177
+ url.searchParams.set("provider", "mor");
178
+ if (options.consentId !== undefined) url.searchParams.set("consentId", options.consentId);
179
+ return { url: url.toString() };
180
+ },
181
+ cancelSubscription: async () => {},
182
+ previewPlanSwitch: async () => ({
183
+ amountDueNow: 667,
184
+ tax: 106,
185
+ currency: "eur",
186
+ credit: 333,
187
+ effectiveAt: "2027-01-15T00:00:00Z",
188
+ }),
189
+ applyPlanSwitch: async (_ctx, options) => {
190
+ if (options.consentId === undefined) {
191
+ throw new Error("gallery MoR mock: plan switch applied without a consent id");
192
+ }
193
+ },
194
+ };
195
+
122
196
  export const galleryProviderFeature: FeatureDefinition = defineFeature(
123
197
  "gallery-mock-provider",
124
198
  (r) => {
125
199
  r.describe("E2E-only mock subscription provider for the consent/termination gallery.");
126
200
  r.requires({ features: ["billing-foundation"] });
127
201
  r.useExtension("subscriptionProvider", GALLERY_PROVIDER, galleryProviderPlugin);
202
+ r.useExtension("subscriptionProvider", GALLERY_MOR_PROVIDER, galleryMorProviderPlugin);
128
203
  },
129
204
  );
130
205
 
@@ -20,6 +20,7 @@ import { GALLERY_TENANT_HOST, GALLERY_TERMS_SLUG } from "./gallery-constants";
20
20
  import {
21
21
  createGalleryBillingFeature,
22
22
  createGalleryEmailFeature,
23
+ createGalleryMorEnableRoute,
23
24
  galleryMailTransport,
24
25
  galleryProviderFeature,
25
26
  galleryScreensFeature,
@@ -29,14 +30,22 @@ const port = Number.parseInt(process.env["PORT"] ?? "4196", 10);
29
30
 
30
31
  const BILLING_FEATURE = "billing-foundation";
31
32
  const EMAIL_FEATURE = "channel-email";
33
+ // The stock app mounts the real Mollie and Dodo providers; the gallery's
34
+ // provider choice is the two mocks, which never call out.
35
+ const REAL_PROVIDER_FEATURES: ReadonlySet<string> = new Set([
36
+ "subscription-mollie",
37
+ "subscription-dodo",
38
+ ]);
32
39
 
33
40
  const features = [
34
- ...devAppOptions.features.map((feature) => {
35
- if (feature.name === BILLING_FEATURE)
36
- return createGalleryBillingFeature(`http://localhost:${port}`);
37
- if (feature.name === EMAIL_FEATURE) return createGalleryEmailFeature();
38
- return feature;
39
- }),
41
+ ...devAppOptions.features
42
+ .filter((feature) => !REAL_PROVIDER_FEATURES.has(feature.name))
43
+ .map((feature) => {
44
+ if (feature.name === BILLING_FEATURE)
45
+ return createGalleryBillingFeature(`http://localhost:${port}`);
46
+ if (feature.name === EMAIL_FEATURE) return createGalleryEmailFeature();
47
+ return feature;
48
+ }),
40
49
  galleryProviderFeature,
41
50
  galleryScreensFeature,
42
51
  ];
@@ -77,6 +86,7 @@ await runDevApp({
77
86
  extraRoutes: [
78
87
  ...createE2eSeedRoutes({ mailOutbox: galleryMailTransport }),
79
88
  createSubscriptionWebhookRoute(),
89
+ createGalleryMorEnableRoute(),
80
90
  ...createContractTerminationRoutes(),
81
91
  ],
82
92
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
3
- "featureCount": 87,
3
+ "featureCount": 88,
4
4
  "features": [
5
5
  {
6
6
  "name": "account-security",
@@ -300,7 +300,7 @@
300
300
  },
301
301
  {
302
302
  "name": "billing-foundation",
303
- "description": "Plugin host for subscription billing — manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers and a dormant billing-plans dashboard screen/panel from the catalog. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event — catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block — use `subscription-stripe` or `subscription-mollie` unless you are writing a new payment provider.",
303
+ "description": "Plugin host for subscription billing. Manages the `read_subscriptions` projection table and exposes 5 domain events (subscription created/updated/canceled, invoice paid/failed) appended by the foundation's own `billing-foundation:write:process-event` write-handler after provider plugins verify and normalize each webhook. Also manages a separate `read_payments` projection table (one row per one-off-payment) fed by its own `payment-received` event and `billing-foundation:write:process-payment-event` write-handler. Also ships `billing-foundation:write:create-checkout-session` and `billing-foundation:write:create-portal-session` write-handlers, a `billing-foundation:query:subscription:list` query handler, and a `createSubscriptionWebhookRoute` factory for the `/api/subscription/webhook/:providerName` extraRoute. `createBillingFoundationFeature({ baseUrl, catalog })` additionally derives a `billing-foundation:query:billing-plans` query, `start-plan-checkout`/`switch-plan` write-handlers, a `plan-switch-preview` query and a dormant billing-plans dashboard screen/panel from the catalog. Several providers can be mounted at once: new checkouts are offered by every enabled provider (enabled means keys set and billingLive on, or simply mounted for plugins without `isBillingEnabled`), the customer picks one in the plan panel and `start-plan-checkout` takes a `providerName`. An existing subscription stays with the provider that stored it, so the plan list and every switch follow that provider. A plan switch is either hosted (`createPlanSwitchSession` answers a url) or in-app (`previewPlanSwitch` shows the proration, `applyPlanSwitch` runs after the customer confirms; `switch-plan` then answers `{ applied: true }` and records the consent). `consumerProtection.providerTexts` overrides the checkout note and the VAT note per provider. Also ships `billing-foundation:write:sync-subscription` (pulls a provider plugin's live subscription state via `retrieveSubscription` and appends drift as a `subscription.updated` event, which catches changes made on the provider's own dashboard that never reached us as a webhook) and the `sync-subscriptions` job (manual-trigger + runOnBoot, perTenant) that dispatches it. Low-level building block: use `subscription-stripe`, `subscription-mollie` or `subscription-dodo` unless you are writing a new payment provider.",
304
304
  "toggleableDefault": null,
305
305
  "requires": [
306
306
  "tenant-lifecycle",
@@ -424,7 +424,7 @@
424
424
  },
425
425
  {
426
426
  "name": "channel-email",
427
- "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
427
+ "description": "Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set, but then only notifications declared with `tenantTransport: true` are delivered: every other notification (password reset, sign-in, invitations) fails with `missing_credentials`, so an app without an app SMTP server must mark every notification that should go out or keep an app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.",
428
428
  "toggleableDefault": null,
429
429
  "requires": [
430
430
  "delivery"
@@ -2467,6 +2467,123 @@
2467
2467
  "recommended": false
2468
2468
  }
2469
2469
  },
2470
+ {
2471
+ "name": "subscription-dodo",
2472
+ "description": "Dodo Payments provider plugin for `billing-foundation`. Dodo acts as merchant of record: it sells to the customer and issues invoice and VAT, so the consumer-protection texts for this provider come from `consumerProtection.providerTexts`. Reads its API key and webhook secret from system config keys with `backing:\"secrets\"` (envelope-encrypted in the secrets store under the system tenant), plus a `billingLive` master switch and an `environment` key (test | live). `environment` is its own key because Dodo serves test and live mode from different API hosts and a live key must never hit the test host or the other way round; `billingLive` only decides whether checkouts are offered at all. All four are runtime config, so keys rotate and prod goes live without a redeploy, and the `mask` on each derives the sysadmin settings screen and nav. Mount via `createSubscriptionDodoFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env to secrets bridge fallbacks (`DODO_PAYMENTS_API_KEY`, `DODO_PAYMENTS_WEBHOOK_KEY`). The plugin always mounts; `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true. Webhooks arrive at `/api/subscription/webhook/dodo` and are verified per the Standard Webhooks spec (HMAC-SHA256 over id, timestamp and the raw body, 5 minute tolerance); events for products outside `priceToTier` or without tenant metadata are dropped. Implements hosted checkout, customer portal, cancel and resume, `retrievePrices` (10-minute TTL cache), `retrieveSubscription` for the sync job, and an in-app plan switch: `previewPlanSwitch` shows the proration before the customer confirms, `applyPlanSwitch` changes the plan without a hosted page and waits for the `plan_changed` webhook to move the tier. A plan switch in Dodo requires all plan products to belong to one Product Collection.",
2473
+ "toggleableDefault": null,
2474
+ "requires": [
2475
+ "billing-foundation",
2476
+ "config",
2477
+ "secrets"
2478
+ ],
2479
+ "optionalRequires": [],
2480
+ "configReads": [],
2481
+ "exposesApis": [],
2482
+ "usesApis": [],
2483
+ "extensionsUsed": [
2484
+ {
2485
+ "extensionName": "subscriptionProvider",
2486
+ "entityName": "dodo"
2487
+ }
2488
+ ],
2489
+ "configKeys": [
2490
+ {
2491
+ "key": "api-key",
2492
+ "qualifiedName": "subscription-dodo:config:api-key",
2493
+ "type": "text",
2494
+ "scope": "system",
2495
+ "default": null,
2496
+ "encrypted": true,
2497
+ "computed": false,
2498
+ "validated": false,
2499
+ "options": null,
2500
+ "bounds": null,
2501
+ "pattern": null,
2502
+ "writeRoles": [
2503
+ "SystemAdmin"
2504
+ ],
2505
+ "readRoles": [
2506
+ "TenantAdmin",
2507
+ "Admin",
2508
+ "SystemAdmin"
2509
+ ]
2510
+ },
2511
+ {
2512
+ "key": "billing-live",
2513
+ "qualifiedName": "subscription-dodo:config:billing-live",
2514
+ "type": "boolean",
2515
+ "scope": "system",
2516
+ "default": false,
2517
+ "encrypted": false,
2518
+ "computed": false,
2519
+ "validated": false,
2520
+ "options": null,
2521
+ "bounds": null,
2522
+ "pattern": null,
2523
+ "writeRoles": [
2524
+ "system",
2525
+ "SystemAdmin"
2526
+ ],
2527
+ "readRoles": [
2528
+ "TenantAdmin",
2529
+ "Admin",
2530
+ "SystemAdmin"
2531
+ ]
2532
+ },
2533
+ {
2534
+ "key": "environment",
2535
+ "qualifiedName": "subscription-dodo:config:environment",
2536
+ "type": "select",
2537
+ "scope": "system",
2538
+ "default": "test",
2539
+ "encrypted": false,
2540
+ "computed": false,
2541
+ "validated": false,
2542
+ "options": [
2543
+ "test",
2544
+ "live"
2545
+ ],
2546
+ "bounds": null,
2547
+ "pattern": null,
2548
+ "writeRoles": [
2549
+ "SystemAdmin"
2550
+ ],
2551
+ "readRoles": [
2552
+ "TenantAdmin",
2553
+ "Admin",
2554
+ "SystemAdmin"
2555
+ ]
2556
+ },
2557
+ {
2558
+ "key": "webhook-secret",
2559
+ "qualifiedName": "subscription-dodo:config:webhook-secret",
2560
+ "type": "text",
2561
+ "scope": "system",
2562
+ "default": null,
2563
+ "encrypted": true,
2564
+ "computed": false,
2565
+ "validated": false,
2566
+ "options": null,
2567
+ "bounds": null,
2568
+ "pattern": null,
2569
+ "writeRoles": [
2570
+ "SystemAdmin"
2571
+ ],
2572
+ "readRoles": [
2573
+ "TenantAdmin",
2574
+ "Admin",
2575
+ "SystemAdmin"
2576
+ ]
2577
+ }
2578
+ ],
2579
+ "secrets": [],
2580
+ "writeHandlers": [],
2581
+ "uiHints": {
2582
+ "displayLabel": "Billing · Dodo Payments",
2583
+ "category": "billing",
2584
+ "recommended": false
2585
+ }
2586
+ },
2470
2587
  {
2471
2588
  "name": "subscription-mollie",
2472
2589
  "description": "Mollie payment provider plugin for `billing-foundation`, covering the DACH/EU mid-market use case. Mount via `createSubscriptionMollieFeature({ apiKey, webhookUrl, priceToTier, priceToConfig })` — the factory validates that `priceToTier` and `priceToConfig` keys are identical at boot time. Implements `verifyAndParseWebhook` (lazy Mollie-API fetch + heuristic event-type mapping) and `createCheckoutSession` (customer + first-payment with `sequenceType=\"first\"`); `createPortalSession` and `cancelSubscription` are not available because Mollie has no customer portal and requires a `customerId` that the plugin contract does not carry.",
@@ -75,6 +75,7 @@ import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/r
75
75
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
76
76
  import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
77
77
  import { createStepDispatcherFeature } from "@cosmicdrift/kumiko-bundled-features/step-dispatcher";
78
+ import { createSubscriptionDodoFeature } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
78
79
  import { createSubscriptionMollieFeature } from "@cosmicdrift/kumiko-bundled-features/subscription-mollie";
79
80
  import { createSubscriptionStripeFeature } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
80
81
  import { createTagsFeature } from "@cosmicdrift/kumiko-bundled-features/tags";
@@ -209,6 +210,11 @@ export const APP_FEATURES = [
209
210
  webhookSecret: "whsec_smoke",
210
211
  priceToTier: { price_smoke: "free" },
211
212
  }),
213
+ createSubscriptionDodoFeature({
214
+ apiKey: "dodo_smoke",
215
+ webhookSecret: "whsec_smoke",
216
+ priceToTier: { pdt_smoke: "free" },
217
+ }),
212
218
  createSubscriptionMollieFeature({
213
219
  apiKey: "test_smoke_key",
214
220
  webhookUrl: "https://smoke.example/webhook",
@@ -416,12 +422,12 @@ export const BOOT_FEATURES = APP_FEATURES.filter(
416
422
  (feature) => feature.name !== FEATURE_TOGGLES_FEATURE,
417
423
  );
418
424
 
419
- // Smoke signup — enables createAuthSelfRegistrationToggleFeature via
420
- // composeFeatures when passed as authOptions (#1521 Option A). Same shape
421
- // as run{Dev,Prod}App's auth.signup; appUrl is a stub (no real mail in boot).
422
425
  // locale-de is mounted, so every translation key must exist in German and English.
423
426
  export const VALIDATE_BOOT_OPTIONS = { requiredLocales: ["de", "en"] } as const;
424
427
 
428
+ // Smoke signup — enables createAuthSelfRegistrationToggleFeature via
429
+ // composeFeatures when passed as authOptions (#1521 Option A). Same shape
430
+ // as run{Dev,Prod}App's auth.signup; appUrl is a stub (no real mail in boot).
425
431
  export const AUTH_COMPOSE_OPTIONS = {
426
432
  signup: {
427
433
  appUrl: "http://localhost:4186/signup",
@@ -75,8 +75,13 @@ features: [..., createSessionsFeature(), mfaFeature, createAccountSecurityFeatur
75
75
  5. **MFA state changes revoke every OTHER live session** (stolen-session defense) — never the session that made the change.
76
76
  6. **Enforcement is opt-in.** Without a tenant enabling `auth-mfa:config:required`, users choose for themselves whether to enroll.
77
77
 
78
+ ## Confirmation code input
79
+
80
+ `src/web/confirmation-code-step.tsx` shows the `Input kind="code"` renderer primitive outside the MFA screen, for example an e-mail or invite confirmation code. Pass `length` (here 6), `autoFocus` and `onComplete`. The input accepts digits only, so pasting "123 456" yields `123456`, and `onComplete` fires once when the value reaches `length`. `autoComplete` defaults to `one-time-code`, so mobile keyboards offer the code from the SMS or mail. After a wrong code the step clears the value and bumps a `key`, which remounts the input and returns focus to it.
81
+
78
82
  ## Tests
79
83
 
80
84
  - **Baseline:** a user without MFA enrolled still gets a JWT straight from `/auth/login`.
81
85
  - **End-to-end:** enroll via `enable-start`/`enable-confirm`, log in (gets a challenge, no JWT), complete via `/auth/mfa/verify` (gets a JWT) — copies the `buildServer({ auth: ... })` block from the sample comment verbatim.
82
86
  - **Account security:** the composed screen boot-validates next to `auth-mfa` and `sessions` and shows a sidebar entry once `nav` is set.
87
+ - **Confirmation code step:** typing or pasting six digits calls `onConfirm` once; a rejected code shows the error, clears the field and refocuses it.
@@ -4,9 +4,12 @@
4
4
  "private": true,
5
5
  "dependencies": {
6
6
  "@cosmicdrift/kumiko-bundled-features": "workspace:*",
7
- "@cosmicdrift/kumiko-framework": "workspace:*"
7
+ "@cosmicdrift/kumiko-framework": "workspace:*",
8
+ "@cosmicdrift/kumiko-renderer": "workspace:*",
9
+ "react": "^19.2.6"
8
10
  },
9
11
  "devDependencies": {
10
- "@cosmicdrift/kumiko-testing": "workspace:*"
12
+ "@cosmicdrift/kumiko-testing": "workspace:*",
13
+ "@types/react": "^19.2.14"
11
14
  }
12
15
  }
@@ -0,0 +1,49 @@
1
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
2
+ import { type ReactNode, useState } from "react";
3
+
4
+ const CONFIRMATION_CODE_LENGTH = 6;
5
+
6
+ export type ConfirmationCodeStepProps = {
7
+ /** Resolves true when the server accepted the code. */
8
+ readonly onConfirm: (code: string) => Promise<boolean>;
9
+ };
10
+
11
+ export function ConfirmationCodeStep({ onConfirm }: ConfirmationCodeStepProps): ReactNode {
12
+ const { Input, Banner } = usePrimitives();
13
+ const [code, setCode] = useState("");
14
+ const [attempt, setAttempt] = useState(0);
15
+ const [pending, setPending] = useState(false);
16
+ const [rejected, setRejected] = useState(false);
17
+
18
+ const confirm = async (submittedCode: string): Promise<void> => {
19
+ if (pending) return;
20
+ setPending(true);
21
+ setRejected(false);
22
+ const accepted = await onConfirm(submittedCode).finally(() => setPending(false));
23
+ if (accepted) return;
24
+ setRejected(true);
25
+ setCode("");
26
+ // The input was disabled while pending and lost focus; remounting restores autoFocus.
27
+ setAttempt((n) => n + 1);
28
+ };
29
+
30
+ return (
31
+ <>
32
+ <Input
33
+ key={attempt}
34
+ kind="code"
35
+ length={CONFIRMATION_CODE_LENGTH}
36
+ id="confirmation-code"
37
+ name="confirmation-code"
38
+ testId="confirmation-code"
39
+ value={code}
40
+ onChange={setCode}
41
+ onComplete={(next) => void confirm(next)}
42
+ disabled={pending}
43
+ hasError={rejected}
44
+ autoFocus
45
+ />
46
+ {rejected ? <Banner variant="error">That code is not valid. Try again.</Banner> : null}
47
+ </>
48
+ );
49
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "extends": "../../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
5
+ "jsx": "react-jsx"
6
+ },
7
+ "include": ["src"]
8
+ }
@@ -10,7 +10,8 @@ instead of keeping a hand-written copy of every feature's variables.
10
10
  `kmsEnvSchema` and `s3EnvSchema`. `composeEnvSchema` collects them and
11
11
  records which feature declared each variable.
12
12
  - **App-level blocks in `extend`**: `buildInfoEnvSchema`, `demoAdminEnvSchema`,
13
- `stripeEnvSchema({ prices })` and `createS3EnvSchema({ prefix })` for a
13
+ `stripeEnvSchema({ prices })`, `dodoEnvSchema({ prices })` and
14
+ `createS3EnvSchema({ prefix })` for a
14
15
  second bucket. These belong to no mounted feature, so the app adds them.
15
16
  - **Typed result**: `parseEnv(composed.schema, …)` returns the merged type,
16
17
  so `parsed.S3_BUCKET` is `string | undefined` and `parsed.APP_PUBLIC_URL` is
@@ -26,6 +27,7 @@ instead of keeping a hand-written copy of every feature's variables.
26
27
  | `smtpEnvSchema` | `channel-email` | `createChannelEmailFeature()` |
27
28
  | `s3EnvSchema`, `createS3EnvSchema({ prefix })` | `file-provider-s3` | `createFileProviderS3Feature({ fromEnv: true })` |
28
29
  | `stripeEnvSchema({ prices })` | `subscription-stripe` | app (`extend`); the feature registers the bridge keys |
30
+ | `dodoEnvSchema({ prices })` | `subscription-dodo` | app (`extend`); the feature registers the bridge keys |
29
31
  | `mfaTokenSecretsEnvSchema` | `auth-mfa` | `createAuthMfaFeature()` |
30
32
  | `buildInfoEnvSchema`, `demoAdminEnvSchema` | `foundation-shared` | app (`extend`) |
31
33
  | `prometheusMetricsEnvSchema` | `framework/observability` | app (`extend`) |
@@ -15,6 +15,7 @@ import {
15
15
  demoAdminEnvSchema,
16
16
  } from "@cosmicdrift/kumiko-bundled-features/foundation-shared";
17
17
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
18
+ import { dodoEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-dodo";
18
19
  import { stripeEnvSchema } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
19
20
  import { frameworkCoreEnvSchema } from "@cosmicdrift/kumiko-dev-server/env-schema";
20
21
  import { composeEnvSchema } from "@cosmicdrift/kumiko-framework/env";
@@ -36,6 +37,7 @@ const appEnvSchema = z.object({
36
37
  ...buildInfoEnvSchema.shape,
37
38
  ...demoAdminEnvSchema.shape,
38
39
  ...stripeEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
40
+ ...dodoEnvSchema({ prices: ["PRO", "TEAM"] }).shape,
39
41
  ...createS3EnvSchema({ prefix: "RELEASES_S3_" }).shape,
40
42
  APP_PUBLIC_URL: z.url().describe("Public origin of the app, used in mail links."),
41
43
  });
@@ -72,7 +72,7 @@ await runProdApp({
72
72
  // Required (1): routes run anonymous, need tenant resolution
73
73
  anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
74
74
  // Required (2): boot check + internal lookup use ctx.templateResolver
75
- extraContext: ({ db }) => ({
75
+ extraContext: ({ db, registry }) => ({
76
76
  templateResolver: createTemplateResolverApi(db, registry),
77
77
  }),
78
78
  });
@@ -28,6 +28,24 @@ exhausted) and no bucket is charged. Each `per` may appear once; an empty list f
28
28
  An anonymous handler needs every bucket to be ip-keyed, and `{ disabled: true, reason }`
29
29
  stays a single declaration.
30
30
 
31
+ ## Cost per payload element
32
+
33
+ A bulk handler books one token per item instead of one per request. Declare `rateLimitCost`
34
+ next to `rateLimit`:
35
+
36
+ ```ts illustration
37
+ r.writeHandler({
38
+ // ...
39
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
40
+ rateLimitCost: (payload) => payload.files.length,
41
+ });
42
+ ```
43
+
44
+ The cost is computed from the validated payload and booked on every declared bucket, so an
45
+ invalid payload costs nothing and a rejected request consumes nothing. Results below 1 count
46
+ as 1. Because it books after the parse, boot rejects `rateLimitCost` on anonymous or ip-limited
47
+ handlers.
48
+
31
49
  ## Per-payload limits
32
50
 
33
51
  A public write handler that mails an address typed by the caller is also limited per
@@ -33,5 +33,17 @@ export function createRateLimitShowcaseFeature(): FeatureDefinition {
33
33
  { per: "tenant+handler", limit: 3, windowSeconds: 3600 },
34
34
  ],
35
35
  });
36
+
37
+ // Bulk upload: a request books one token per item instead of one per request. The cost
38
+ // comes from the validated payload, so a schema-invalid request costs nothing; a request
39
+ // that would exceed the remaining budget is rejected and consumes nothing.
40
+ r.writeHandler({
41
+ name: "bulk-upload",
42
+ schema: z.object({ files: z.array(z.string()).min(1).max(20) }),
43
+ handler: async () => ({ isSuccess: true as const, data: {} }),
44
+ access: { roles: ["Admin", "User"] },
45
+ rateLimit: { per: "tenant+handler", limit: 20, windowSeconds: 600 },
46
+ rateLimitCost: (payload) => payload.files.length,
47
+ });
36
48
  });
37
49
  }
@@ -1,12 +0,0 @@
1
- import { PII_ERASED_SENTINEL } from "@cosmicdrift/kumiko-framework/crypto";
2
- import { decryptStoredPii } from "../../shared/index.ts";
3
-
4
- // findForAuth returns the stored column, which is ciphertext under an active KMS;
5
- // channel-email refuses to mail a ciphertext address. Null for an erased subject.
6
- export async function openStoredUserEmail(
7
- stored: string,
8
- fallbackRequestId: string,
9
- ): Promise<string | null> {
10
- const email = await decryptStoredPii(stored, "email", fallbackRequestId);
11
- return email === PII_ERASED_SENTINEL ? null : email;
12
- }