@cosmicdrift/kumiko-bundled-features 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 (212) hide show
  1. package/dist/agent-tools/tool-dispatch.d.ts +7 -7
  2. package/dist/agent-tools/tool-dispatch.js +9 -9
  3. package/dist/audit/handlers/audit-events.js +1 -1
  4. package/dist/auth-mfa/feature-exports.d.ts +2 -2
  5. package/dist/auth-mfa/feature.js +8 -0
  6. package/dist/auth-mfa/web/i18n.js +5 -1
  7. package/dist/auth-mfa/web/mfa-code-or-recovery-field.d.ts +11 -0
  8. package/dist/auth-mfa/web/mfa-code-or-recovery-field.js +12 -0
  9. package/dist/auth-mfa/web/mfa-disable-dialog.js +14 -3
  10. package/dist/auth-mfa/web/mfa-regenerate-recovery-dialog.js +14 -3
  11. package/dist/auth-mfa/web/mfa-setup-preauth-screen.js +10 -5
  12. package/dist/auth-mfa/web/mfa-verify-screen.js +26 -5
  13. package/dist/billing-foundation/checkout-core.d.ts +9 -14
  14. package/dist/billing-foundation/checkout-core.js +24 -39
  15. package/dist/billing-foundation/consumer-protection/confirmation-mail.js +8 -2
  16. package/dist/billing-foundation/consumer-protection/consent-gate.d.ts +1 -0
  17. package/dist/billing-foundation/consumer-protection/consent-gate.js +1 -0
  18. package/dist/billing-foundation/consumer-protection/issue-confirmation.js +9 -3
  19. package/dist/billing-foundation/consumer-protection/mail-notifications.js +3 -1
  20. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.d.ts +4 -2
  21. package/dist/billing-foundation/consumer-protection/termination-receipt-limit.js +6 -5
  22. package/dist/billing-foundation/consumer-protection/termination-record.d.ts +5 -1
  23. package/dist/billing-foundation/consumer-protection/termination-record.js +27 -17
  24. package/dist/billing-foundation/consumer-protection/termination-request.js +12 -7
  25. package/dist/billing-foundation/consumer-protection/termination-revoke.js +12 -7
  26. package/dist/billing-foundation/consumer-protection/termination-texts.d.ts +1 -1
  27. package/dist/billing-foundation/consumer-protection/termination-texts.js +3 -0
  28. package/dist/billing-foundation/consumer-protection/withdrawal-period.d.ts +5 -2
  29. package/dist/billing-foundation/consumer-protection/withdrawal-period.js +8 -9
  30. package/dist/billing-foundation/events.d.ts +1 -0
  31. package/dist/billing-foundation/events.js +1 -0
  32. package/dist/billing-foundation/feature.js +3 -1
  33. package/dist/billing-foundation/handlers/billing-plans.query.js +35 -5
  34. package/dist/billing-foundation/handlers/create-checkout-session.write.js +1 -0
  35. package/dist/billing-foundation/handlers/plan-switch-preview.query.d.ts +13 -0
  36. package/dist/billing-foundation/handlers/plan-switch-preview.query.js +48 -0
  37. package/dist/billing-foundation/handlers/process-event.write.d.ts +6 -0
  38. package/dist/billing-foundation/handlers/process-event.write.js +8 -2
  39. package/dist/billing-foundation/handlers/start-plan-checkout.write.js +32 -5
  40. package/dist/billing-foundation/handlers/switch-plan.write.d.ts +2 -2
  41. package/dist/billing-foundation/handlers/switch-plan.write.js +56 -64
  42. package/dist/billing-foundation/handlers/sync-subscription.write.js +12 -1
  43. package/dist/billing-foundation/i18n.js +22 -0
  44. package/dist/billing-foundation/index.d.ts +1 -1
  45. package/dist/billing-foundation/plan-catalog.d.ts +21 -5
  46. package/dist/billing-foundation/plan-catalog.js +25 -16
  47. package/dist/billing-foundation/plan-switch.d.ts +12 -0
  48. package/dist/billing-foundation/plan-switch.js +60 -0
  49. package/dist/billing-foundation/types.d.ts +57 -1
  50. package/dist/billing-foundation/types.js +9 -0
  51. package/dist/billing-foundation/validate-options.js +17 -0
  52. package/dist/billing-foundation/web/billing-plans-panel.js +78 -32
  53. package/dist/billing-foundation/web/checkout-consent-dialog.d.ts +17 -3
  54. package/dist/billing-foundation/web/checkout-consent-dialog.js +14 -10
  55. package/dist/billing-foundation/web/constants.d.ts +4 -0
  56. package/dist/billing-foundation/web/constants.js +4 -0
  57. package/dist/billing-foundation/web/plan-price.d.ts +15 -0
  58. package/dist/billing-foundation/web/plan-price.js +32 -0
  59. package/dist/billing-foundation/web/plan-switch-confirm-dialog.d.ts +15 -0
  60. package/dist/billing-foundation/web/plan-switch-confirm-dialog.js +66 -0
  61. package/dist/cap-counter/cap-reservation.js +5 -1
  62. package/dist/cap-counter/enforce-cap.js +2 -1
  63. package/dist/cap-counter/stock-cap-guard.js +7 -1
  64. package/dist/channel-email/feature.js +1 -1
  65. package/dist/config/handlers/cascade.query.d.ts +5 -0
  66. package/dist/config/handlers/cascade.query.js +4 -3
  67. package/dist/config/handlers/values.query.d.ts +5 -0
  68. package/dist/config/handlers/values.query.js +4 -3
  69. package/dist/config/write-helpers.d.ts +1 -0
  70. package/dist/config/write-helpers.js +18 -9
  71. package/dist/crypto-shredding/handlers/forget-subject.write.js +13 -10
  72. package/dist/delivery/channel-context.js +6 -3
  73. package/dist/delivery/db/queries/preferences.js +2 -2
  74. package/dist/delivery/delivery-service.js +22 -7
  75. package/dist/delivery/handlers/preferences.query.d.ts +1 -1
  76. package/dist/delivery/handlers/preferences.query.js +20 -1
  77. package/dist/delivery/handlers/unsubscribe-page-chrome.query.js +1 -1
  78. package/dist/delivery/unsubscribe-link.d.ts +3 -1
  79. package/dist/delivery/unsubscribe-page-strings.d.ts +1 -0
  80. package/dist/delivery/unsubscribe-page-strings.js +1 -0
  81. package/dist/delivery/unsubscribe-page.d.ts +2 -0
  82. package/dist/delivery/unsubscribe-page.js +5 -0
  83. package/dist/delivery/unsubscribe.js +25 -7
  84. package/dist/file-foundation/index.d.ts +1 -1
  85. package/dist/file-foundation/index.js +1 -1
  86. package/dist/file-foundation/read-file-ref-content.d.ts +3 -0
  87. package/dist/file-foundation/read-file-ref-content.js +6 -1
  88. package/dist/file-provider-s3/env-named-feature.d.ts +0 -2
  89. package/dist/form-draft/db/queries/cleanup.js +2 -2
  90. package/dist/form-draft/decrypt-draft.js +5 -1
  91. package/dist/host-routing/dispatch.js +11 -20
  92. package/dist/inbound-mail-foundation/handlers/update-account.write.d.ts +2 -2
  93. package/dist/inbound-mail-foundation/handlers/update-account.write.js +12 -4
  94. package/dist/inbound-mail-foundation/i18n.js +0 -1
  95. package/dist/inbound-mail-foundation/screens.js +0 -3
  96. package/dist/inbound-mail-foundation/types.d.ts +4 -3
  97. package/dist/inbound-mail-foundation/watch-supervisor.js +28 -11
  98. package/dist/jobs/feature.d.ts +1 -0
  99. package/dist/jobs/feature.js +8 -4
  100. package/dist/jobs/handlers/tenant-failures.query.d.ts +1 -1
  101. package/dist/jobs/handlers/tenant-failures.query.js +45 -41
  102. package/dist/jobs/handlers/tenant-runs.query.d.ts +1 -1
  103. package/dist/jobs/handlers/tenant-runs.query.js +54 -50
  104. package/dist/jobs/job-run-logger.js +5 -4
  105. package/dist/mail-foundation/feature.d.ts +3 -3
  106. package/dist/mail-foundation/feature.js +3 -0
  107. package/dist/personal-access-tokens/feature.d.ts +1 -1
  108. package/dist/personal-access-tokens/feature.js +4 -0
  109. package/dist/renderer-pdf/render-table-pdf.d.ts +1 -1
  110. package/dist/renderer-pdf/render-table-pdf.js +22 -10
  111. package/dist/sessions/feature.d.ts +2 -2
  112. package/dist/sessions/feature.js +4 -0
  113. package/dist/shared/decrypt-stored-pii.d.ts +1 -0
  114. package/dist/shared/decrypt-stored-pii.js +6 -1
  115. package/dist/shared/index.d.ts +1 -1
  116. package/dist/shared/index.js +1 -1
  117. package/dist/shared/mail-template-body.d.ts +6 -2
  118. package/dist/shared/mail-template-body.js +7 -4
  119. package/dist/step-dispatcher/feature.js +1 -0
  120. package/dist/subscription-dodo/client.d.ts +17 -0
  121. package/dist/subscription-dodo/client.js +31 -0
  122. package/dist/subscription-dodo/constants.d.ts +12 -0
  123. package/dist/subscription-dodo/constants.js +19 -0
  124. package/dist/subscription-dodo/feature.d.ts +37 -0
  125. package/dist/subscription-dodo/feature.js +144 -0
  126. package/dist/subscription-dodo/index.d.ts +2 -0
  127. package/dist/subscription-dodo/index.js +4 -0
  128. package/dist/subscription-dodo/plugin-methods.d.ts +23 -0
  129. package/dist/subscription-dodo/plugin-methods.js +260 -0
  130. package/dist/subscription-dodo/runtime.d.ts +27 -0
  131. package/dist/subscription-dodo/runtime.js +89 -0
  132. package/dist/subscription-dodo/verify-webhook.d.ts +37 -0
  133. package/dist/subscription-dodo/verify-webhook.js +189 -0
  134. package/dist/subscription-stripe/feature.js +1 -0
  135. package/dist/subscription-stripe/runtime.js +7 -6
  136. package/dist/template-resolver/api.d.ts +3 -2
  137. package/dist/template-resolver/api.js +24 -2
  138. package/dist/template-resolver/compile-document-body.js +1 -0
  139. package/dist/template-resolver/feature.js +2 -2
  140. package/dist/template-resolver/handlers/bundle-import.js +3 -3
  141. package/dist/template-resolver/handlers/copy-locale.write.d.ts +5 -1
  142. package/dist/template-resolver/handlers/copy-locale.write.js +22 -7
  143. package/dist/template-resolver/handlers/diff-to-system.query.js +3 -2
  144. package/dist/template-resolver/handlers/document-base.js +3 -2
  145. package/dist/template-resolver/handlers/find-by-id.query.js +3 -2
  146. package/dist/template-resolver/handlers/list.query.js +5 -6
  147. package/dist/template-resolver/handlers/overview.query.d.ts +3 -0
  148. package/dist/template-resolver/handlers/overview.query.js +6 -3
  149. package/dist/template-resolver/handlers/tenant-override-counts.query.js +1 -3
  150. package/dist/template-resolver/handlers/tenant-override-selection.d.ts +2 -1
  151. package/dist/template-resolver/handlers/tenant-overrides.query.js +28 -5
  152. package/dist/template-resolver/handlers/usage.query.d.ts +2 -1
  153. package/dist/template-resolver/handlers/usage.query.js +26 -16
  154. package/dist/template-resolver/handlers/validate.query.js +7 -2
  155. package/dist/template-resolver/seeding.js +26 -10
  156. package/dist/template-resolver/store-template-resource.d.ts +1 -0
  157. package/dist/template-resolver/store-template-resource.js +19 -4
  158. package/dist/template-resolver/template-delivery-policy.d.ts +0 -5
  159. package/dist/template-resolver/template-delivery-policy.js +0 -6
  160. package/dist/template-resolver/template-meta.js +7 -4
  161. package/dist/template-resolver/template-preview.js +4 -3
  162. package/dist/template-resolver/web/constants.d.ts +2 -2
  163. package/dist/template-resolver/web/constants.js +3 -3
  164. package/dist/template-resolver/web/index.d.ts +1 -0
  165. package/dist/template-resolver/web/index.js +1 -0
  166. package/dist/template-resolver/web/mail-layout-constants.d.ts +5 -0
  167. package/dist/template-resolver/web/mail-layout-constants.js +7 -0
  168. package/dist/tenant/handlers/member-directory.query.d.ts +1 -0
  169. package/dist/tenant/handlers/member-directory.query.js +10 -3
  170. package/dist/tenant/handlers/tenant-directory.query.d.ts +1 -0
  171. package/dist/tenant/handlers/tenant-directory.query.js +8 -4
  172. package/dist/tenant-lifecycle/constants.d.ts +1 -0
  173. package/dist/tenant-lifecycle/constants.js +4 -0
  174. package/dist/tenant-lifecycle/run-tenant-destroy.js +2 -2
  175. package/dist/tier-engine/handlers/set-tenant-tier.write.d.ts +2 -3
  176. package/dist/tier-engine/handlers/set-tenant-tier.write.js +4 -1
  177. package/dist/tier-engine/web/constants.js +2 -2
  178. package/dist/user-data-rights/run-export-jobs.js +3 -4
  179. package/dist/user-data-rights/run-forget-cleanup.js +2 -1
  180. package/dist/waitlist/handlers/export-purge.write.js +2 -1
  181. package/dist/waitlist/handlers/submit.write.js +5 -5
  182. package/dist/waitlist/import.d.ts +1 -0
  183. package/dist/waitlist/import.js +4 -0
  184. package/dist/waitlist/legacy-migrate.js +17 -6
  185. package/dist/waitlist/link-on-signup.js +18 -6
  186. package/dist/waitlist/options.d.ts +3 -0
  187. package/package.json +19 -9
  188. package/src/agent-tools/changes.json +6 -0
  189. package/src/audit/changes.json +12 -0
  190. package/src/auth-mfa/changes.json +6 -0
  191. package/src/billing-foundation/changes.json +55 -0
  192. package/src/cap-counter/changes.json +18 -0
  193. package/src/channel-email/changes.json +6 -0
  194. package/src/config/changes.json +12 -0
  195. package/src/crypto-shredding/changes.json +6 -0
  196. package/src/delivery/changes.json +30 -0
  197. package/src/form-draft/changes.json +6 -0
  198. package/src/host-routing/changes.json +6 -0
  199. package/src/inbound-mail-foundation/changes.json +18 -0
  200. package/src/jobs/changes.json +13 -0
  201. package/src/mail-foundation/changes.json +6 -0
  202. package/src/renderer-pdf/changes.json +6 -0
  203. package/src/step-dispatcher/changes.json +6 -0
  204. package/src/subscription-dodo/changes.json +14 -0
  205. package/src/subscription-stripe/changes.json +12 -0
  206. package/src/template-resolver/changes.json +48 -0
  207. package/src/tenant/changes.json +6 -0
  208. package/src/tenant-lifecycle/changes.json +6 -0
  209. package/src/tier-engine/changes.json +6 -0
  210. package/src/waitlist/changes.json +18 -0
  211. package/dist/billing-foundation/consumer-protection/stored-user-email.d.ts +0 -1
  212. package/dist/billing-foundation/consumer-protection/stored-user-email.js +0 -8
@@ -0,0 +1,31 @@
1
+ // Thin JSON-over-fetch client for the Dodo Payments REST API. All traffic goes
2
+ // through the injected `send`, which defaults to the SSRF-safe egress in
3
+ // feature.ts and is replaced by a mock in tests.
4
+ import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
5
+ // Never carries the response body or the key: both can hold customer data or secrets.
6
+ export class DodoHttpError extends Error {
7
+ status;
8
+ constructor(request, status) {
9
+ super(`subscription-dodo: ${request.method} ${request.path} failed with status ${status}`);
10
+ this.name = "DodoHttpError";
11
+ this.status = status;
12
+ }
13
+ }
14
+ export function createDodoHttp(send) {
15
+ return async (target, request) => {
16
+ const query = request.query ? `?${new URLSearchParams(request.query).toString()}` : "";
17
+ const response = await send(`${target.baseUrl}${request.path}${query}`, {
18
+ method: request.method,
19
+ headers: {
20
+ Authorization: `Bearer ${target.apiKey}`,
21
+ Accept: "application/json",
22
+ ...(request.body !== undefined && { "Content-Type": "application/json" }),
23
+ },
24
+ ...(request.body !== undefined && { body: JSON.stringify(request.body) }),
25
+ });
26
+ if (!response.ok)
27
+ throw new DodoHttpError(request, response.status);
28
+ const text = await response.text();
29
+ return text.length === 0 ? null : parseJsonOrThrow(text, "subscription-dodo response");
30
+ };
31
+ }
@@ -0,0 +1,12 @@
1
+ export declare const SUBSCRIPTION_DODO_FEATURE: "subscription-dodo";
2
+ export declare const DODO_PROVIDER_NAME: "dodo";
3
+ export declare const DODO_API_KEY_CONFIG: "api-key";
4
+ export declare const DODO_WEBHOOK_SECRET_CONFIG: "webhook-secret";
5
+ export declare const DODO_BILLING_LIVE_CONFIG: "billingLive";
6
+ export declare const DODO_ENVIRONMENT_CONFIG: "environment";
7
+ export declare const DodoEnvironments: {
8
+ readonly test: "test";
9
+ readonly live: "live";
10
+ };
11
+ export type DodoEnvironment = (typeof DodoEnvironments)[keyof typeof DodoEnvironments];
12
+ export declare const DODO_BASE_URLS: Readonly<Record<DodoEnvironment, string>>;
@@ -0,0 +1,19 @@
1
+ export const SUBSCRIPTION_DODO_FEATURE = "subscription-dodo";
2
+ // Must match the path segment in the webhook URL `/api/subscription/webhook/dodo`.
3
+ export const DODO_PROVIDER_NAME = "dodo";
4
+ // Config-key short-names, qualified to `subscription-dodo:config:<name>` at
5
+ // registry-build. api-key + webhook-secret are backing:"secrets".
6
+ export const DODO_API_KEY_CONFIG = "api-key";
7
+ export const DODO_WEBHOOK_SECRET_CONFIG = "webhook-secret";
8
+ export const DODO_BILLING_LIVE_CONFIG = "billingLive";
9
+ export const DODO_ENVIRONMENT_CONFIG = "environment";
10
+ // Separate from billingLive on purpose: staging runs Dodo's test API with
11
+ // billingLive on, production flips both.
12
+ export const DodoEnvironments = {
13
+ test: "test",
14
+ live: "live",
15
+ };
16
+ export const DODO_BASE_URLS = {
17
+ test: "https://test.dodopayments.com",
18
+ live: "https://live.dodopayments.com",
19
+ };
@@ -0,0 +1,37 @@
1
+ import { type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { type FeatureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
3
+ import * as z from "zod";
4
+ import type { DodoSend } from "./client.ts";
5
+ /** Bridge contract: both fields are optional fallbacks for the secrets store. */
6
+ export declare const subscriptionDodoEnvSchema: z.ZodObject<{
7
+ DODO_PAYMENTS_API_KEY: z.ZodOptional<z.ZodString>;
8
+ DODO_PAYMENTS_WEBHOOK_KEY: z.ZodOptional<z.ZodString>;
9
+ }, z.core.$strip>;
10
+ type DodoProductEnvShape<TPrice extends string> = {
11
+ [Key in `DODO_PRODUCT_${TPrice}`]: z.ZodOptional<z.ZodString>;
12
+ };
13
+ /** Dodo env block plus one `DODO_PRODUCT_<NAME>` slot per plan product the app
14
+ * maps to a tier, e.g. `dodoEnvSchema({ prices: ["PRO", "TEAM"] })`.
15
+ * Shares its bridge fields with `subscriptionDodoEnvSchema`, so spreading
16
+ * it into `composeEnvSchema({ extend })` next to the mounted feature is no
17
+ * key clash. */
18
+ export declare function dodoEnvSchema<const TPrice extends string>(options: {
19
+ readonly prices: readonly TPrice[];
20
+ }): z.ZodObject<{
21
+ DODO_PAYMENTS_API_KEY: z.ZodOptional<z.ZodString>;
22
+ DODO_PAYMENTS_WEBHOOK_KEY: z.ZodOptional<z.ZodString>;
23
+ } & DodoProductEnvShape<TPrice> extends infer T ? { -readonly [P in keyof T]: T[P]; } : never, z.core.$strip>;
24
+ export type SubscriptionDodoOptions = {
25
+ /** Bridge-fallback API key; the `api-key` config key wins when set. */
26
+ readonly apiKey?: string;
27
+ /** Bridge-fallback webhook secret; the `webhook-secret` config key wins when set. */
28
+ readonly webhookSecret?: string;
29
+ /** Dodo product id → app tier name. Products absent here drop the event. */
30
+ readonly priceToTier?: Readonly<Record<string, string>>;
31
+ /** Product ids a mode:"payment" checkout may target; empty = one-off checkouts closed. */
32
+ readonly oneOffPriceIds?: readonly string[];
33
+ /** HTTP transport; defaults to the SSRF-safe external egress. Test seam. */
34
+ readonly send?: DodoSend;
35
+ };
36
+ export declare function createSubscriptionDodoFeature(options?: SubscriptionDodoOptions): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionDodoEnvSchema.shape>;
37
+ export {};
@@ -0,0 +1,144 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // subscription-dodo — Dodo Payments (merchant of record) plugin for the
4
+ // billing-foundation plugin API. Credentials, the billing-live master switch and
5
+ // the API environment (test | live) are config keys resolved at call time, like
6
+ // subscription-stripe. `apiKey` / `webhookSecret` options are env→secrets bridge
7
+ // fallbacks.
8
+ import { access, createSystemConfig, defineFeature, } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
10
+ import { egress } from "@cosmicdrift/kumiko-framework/http";
11
+ import * as z from "zod";
12
+ import { DODO_API_KEY_CONFIG, DODO_BILLING_LIVE_CONFIG, DODO_ENVIRONMENT_CONFIG, DODO_PROVIDER_NAME, DODO_WEBHOOK_SECRET_CONFIG, DodoEnvironments, SUBSCRIPTION_DODO_FEATURE, } from "./constants.js";
13
+ import { createDodoApplyPlanSwitch, createDodoCancelSubscription, createDodoCheckoutSession, createDodoPortalSession, createDodoPreviewPlanSwitch, createDodoPriceCache, createDodoResumeSubscription, createDodoRetrievePrices, createDodoRetrieveSubscription, } from "./plugin-methods.js";
14
+ import { createDodoRuntimes } from "./runtime.js";
15
+ import { verifyAndParseDodoWebhook } from "./verify-webhook.js";
16
+ /** Bridge contract: both fields are optional fallbacks for the secrets store. */
17
+ export const subscriptionDodoEnvSchema = z.object({
18
+ DODO_PAYMENTS_API_KEY: z
19
+ .string()
20
+ .min(1)
21
+ .describe("Dodo Payments API key. Bridge-fallback — prefer the secret.")
22
+ .meta({ kumiko: { pulumi: { secret: true } } })
23
+ .optional(),
24
+ DODO_PAYMENTS_WEBHOOK_KEY: z
25
+ .string()
26
+ .min(1)
27
+ .describe("Dodo Payments webhook signing key (`whsec_...`). Bridge-fallback — prefer the secret.")
28
+ .meta({ kumiko: { pulumi: { secret: true } } })
29
+ .optional(),
30
+ });
31
+ /** Dodo env block plus one `DODO_PRODUCT_<NAME>` slot per plan product the app
32
+ * maps to a tier, e.g. `dodoEnvSchema({ prices: ["PRO", "TEAM"] })`.
33
+ * Shares its bridge fields with `subscriptionDodoEnvSchema`, so spreading
34
+ * it into `composeEnvSchema({ extend })` next to the mounted feature is no
35
+ * key clash. */
36
+ export function dodoEnvSchema(options) {
37
+ const productShape = Object.fromEntries(options.prices.map((price) => [
38
+ `DODO_PRODUCT_${price}`,
39
+ z
40
+ .string()
41
+ .regex(/^pdt_/, `DODO_PRODUCT_${price} must start with 'pdt_'`)
42
+ .optional()
43
+ .describe(`Dodo Payments product id (\`pdt_...\`) of the ${price} plan.`),
44
+ ]));
45
+ return z.object({ ...subscriptionDodoEnvSchema.shape, ...productShape });
46
+ }
47
+ // Validated once at feature-build time so a typo fails app startup instead of
48
+ // silently widening or narrowing the one-off allowlist at request time.
49
+ function validateOneOffPriceIds(priceIds) {
50
+ const seen = new Set();
51
+ for (const priceId of priceIds) {
52
+ if (priceId.length === 0) {
53
+ throw new Error("subscription-dodo: oneOffPriceIds must not contain an empty string");
54
+ }
55
+ if (seen.has(priceId)) {
56
+ throw new Error(`subscription-dodo: duplicate oneOffPriceIds entry "${priceId}"`);
57
+ }
58
+ seen.add(priceId);
59
+ }
60
+ }
61
+ export function createSubscriptionDodoFeature(options = {}) {
62
+ validateOneOffPriceIds(options.oneOffPriceIds ?? []);
63
+ const feature = defineFeature(SUBSCRIPTION_DODO_FEATURE, (r) => {
64
+ r.describe('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.');
65
+ r.uiHints({
66
+ displayLabel: "Billing · Dodo Payments",
67
+ category: "billing",
68
+ recommended: false,
69
+ });
70
+ r.requires({ features: ["billing-foundation"] });
71
+ r.requires({ features: ["config"] });
72
+ r.requires({ features: ["secrets"] });
73
+ r.envSchema(subscriptionDodoEnvSchema);
74
+ const configKeys = r.config({
75
+ keys: {
76
+ [DODO_API_KEY_CONFIG]: createSystemConfig("text", {
77
+ backing: "secrets",
78
+ write: access.systemAdmin,
79
+ read: access.admin,
80
+ mask: { title: "subscription-dodo.api-key", icon: "key", order: 1 },
81
+ }),
82
+ [DODO_WEBHOOK_SECRET_CONFIG]: createSystemConfig("text", {
83
+ backing: "secrets",
84
+ write: access.systemAdmin,
85
+ read: access.admin,
86
+ mask: { title: "subscription-dodo.webhook-secret", icon: "shield", order: 2 },
87
+ }),
88
+ [DODO_ENVIRONMENT_CONFIG]: createSystemConfig("select", {
89
+ default: DodoEnvironments.test,
90
+ options: [DodoEnvironments.test, DodoEnvironments.live],
91
+ write: access.systemAdmin,
92
+ read: access.admin,
93
+ mask: { title: "subscription-dodo.environment", icon: "server", order: 3 },
94
+ }),
95
+ [DODO_BILLING_LIVE_CONFIG]: createSystemConfig("boolean", {
96
+ default: false,
97
+ write: access.privileged,
98
+ read: access.admin,
99
+ mask: { title: "subscription-dodo.billing-live", icon: "rocket", order: 4 },
100
+ }),
101
+ },
102
+ });
103
+ r.translations({
104
+ keys: {
105
+ "subscription-dodo.api-key": { en: "Dodo Payments API key" },
106
+ "subscription-dodo.webhook-secret": { en: "Dodo Payments webhook secret" },
107
+ "subscription-dodo.environment": { en: "Dodo Payments environment" },
108
+ "subscription-dodo.billing-live": { en: "Live billing" },
109
+ "subscription-dodo.settings": { en: "Dodo Payments" },
110
+ "screen:subscription-dodo-system.title": { en: "Dodo Payments" },
111
+ },
112
+ });
113
+ const priceToTier = options.priceToTier ?? {};
114
+ const runtimes = createDodoRuntimes({
115
+ apiKeyHandle: configKeys[DODO_API_KEY_CONFIG],
116
+ webhookSecretHandle: configKeys[DODO_WEBHOOK_SECRET_CONFIG],
117
+ billingLiveHandle: configKeys[DODO_BILLING_LIVE_CONFIG],
118
+ environmentHandle: configKeys[DODO_ENVIRONMENT_CONFIG],
119
+ send: options.send ?? egress({ kind: "external" }),
120
+ fallback: {
121
+ ...(options.apiKey !== undefined && { apiKey: options.apiKey }),
122
+ ...(options.webhookSecret !== undefined && { webhookSecret: options.webhookSecret }),
123
+ },
124
+ });
125
+ const plugin = {
126
+ displayName: "Dodo Payments",
127
+ verifyAndParseWebhook: verifyAndParseDodoWebhook(runtimes.webhook, { priceToTier }),
128
+ createCheckoutSession: createDodoCheckoutSession(runtimes.ctx),
129
+ createPortalSession: createDodoPortalSession(runtimes.ctx),
130
+ cancelSubscription: createDodoCancelSubscription(runtimes.ctx),
131
+ resumeSubscription: createDodoResumeSubscription(runtimes.ctx),
132
+ priceToTier,
133
+ oneOffPriceIds: options.oneOffPriceIds ?? [],
134
+ isBillingEnabled: runtimes.ctx.isBillingEnabled,
135
+ retrievePrices: createDodoRetrievePrices(runtimes.ctx, createDodoPriceCache()),
136
+ retrieveSubscription: createDodoRetrieveSubscription(runtimes.ctx, { priceToTier }),
137
+ previewPlanSwitch: createDodoPreviewPlanSwitch(runtimes.ctx),
138
+ applyPlanSwitch: createDodoApplyPlanSwitch(runtimes.ctx),
139
+ };
140
+ r.useExtension("subscriptionProvider", DODO_PROVIDER_NAME, plugin);
141
+ return { configKeys };
142
+ });
143
+ return featureWithEnvSchema(feature, subscriptionDodoEnvSchema);
144
+ }
@@ -0,0 +1,2 @@
1
+ export { DODO_BASE_URLS, DODO_PROVIDER_NAME, type DodoEnvironment, DodoEnvironments, SUBSCRIPTION_DODO_FEATURE, } from "./constants.ts";
2
+ export { createSubscriptionDodoFeature, dodoEnvSchema, type SubscriptionDodoOptions, subscriptionDodoEnvSchema, } from "./feature.ts";
@@ -0,0 +1,4 @@
1
+ // Public API of the subscription-dodo bundled feature. The webhook verifier,
2
+ // client and plugin methods are internal to feature.ts.
3
+ export { DODO_BASE_URLS, DODO_PROVIDER_NAME, DodoEnvironments, SUBSCRIPTION_DODO_FEATURE, } from "./constants.js";
4
+ export { createSubscriptionDodoFeature, dodoEnvSchema, subscriptionDodoEnvSchema, } from "./feature.js";
@@ -0,0 +1,23 @@
1
+ import { type ProviderPrice, type SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
2
+ import type { DodoCtxRuntime } from "./runtime.ts";
3
+ type PluginMethod<TName extends keyof SubscriptionProviderPlugin> = NonNullable<SubscriptionProviderPlugin[TName]>;
4
+ export declare function createDodoCheckoutSession(runtime: DodoCtxRuntime): PluginMethod<"createCheckoutSession">;
5
+ export declare function createDodoPortalSession(runtime: DodoCtxRuntime): PluginMethod<"createPortalSession">;
6
+ export declare function createDodoCancelSubscription(runtime: DodoCtxRuntime): PluginMethod<"cancelSubscription">;
7
+ export declare function createDodoResumeSubscription(runtime: DodoCtxRuntime): PluginMethod<"resumeSubscription">;
8
+ export type DodoPriceCache = {
9
+ readonly get: (priceId: string) => ProviderPrice | null | undefined;
10
+ readonly set: (priceId: string, price: ProviderPrice | null) => void;
11
+ };
12
+ export type DodoPriceCacheOptions = {
13
+ readonly ttlMs?: number;
14
+ readonly now?: () => number;
15
+ };
16
+ export declare function createDodoPriceCache({ ttlMs, now, }?: DodoPriceCacheOptions): DodoPriceCache;
17
+ export declare function createDodoRetrievePrices(runtime: DodoCtxRuntime, cache: DodoPriceCache): PluginMethod<"retrievePrices">;
18
+ export declare function createDodoPreviewPlanSwitch(runtime: DodoCtxRuntime): PluginMethod<"previewPlanSwitch">;
19
+ export declare function createDodoApplyPlanSwitch(runtime: DodoCtxRuntime): PluginMethod<"applyPlanSwitch">;
20
+ export declare function createDodoRetrieveSubscription(runtime: DodoCtxRuntime, options: {
21
+ readonly priceToTier: Readonly<Record<string, string>>;
22
+ }): PluginMethod<"retrieveSubscription">;
23
+ export {};
@@ -0,0 +1,260 @@
1
+ // Dodo Payments plugin methods for the post-tenant phase. Option shapes are
2
+ // derived from the plugin contract so a contract change fails at compile time.
3
+ // Every response is validated with Zod; every non-2xx throws a DodoHttpError.
4
+ import { KNOWN_RECURRING_INTERVALS, SubscriptionCancelTimings, } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
5
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
6
+ import * as z from "zod";
7
+ import { DodoHttpError } from "./client.js";
8
+ import { dodoSubscriptionSchema, mapDodoSubscriptionState } from "./verify-webhook.js";
9
+ const NOT_FOUND = 404;
10
+ function subscriptionPath(providerSubscriptionId) {
11
+ return `/subscriptions/${encodeURIComponent(providerSubscriptionId)}`;
12
+ }
13
+ function forcedLanguage(locale) {
14
+ return (locale.split(/[-_]/)[0] ?? locale).toLowerCase();
15
+ }
16
+ // =============================================================================
17
+ // Checkout + portal + cancel/resume
18
+ // =============================================================================
19
+ const checkoutResponseSchema = z.object({
20
+ session_id: z.string(),
21
+ checkout_url: z.string().nullable(),
22
+ });
23
+ export function createDodoCheckoutSession(runtime) {
24
+ return async (ctx, options) => {
25
+ // No Dodo session may be created while billing is not live.
26
+ await runtime.assertBillingLive(ctx);
27
+ const response = checkoutResponseSchema.parse(await runtime.call(ctx, {
28
+ method: "POST",
29
+ path: "/checkouts",
30
+ body: {
31
+ product_cart: [{ product_id: options.priceId, quantity: 1 }],
32
+ ...(options.providerCustomerId && {
33
+ customer: { customer_id: options.providerCustomerId },
34
+ }),
35
+ return_url: options.successUrl,
36
+ cancel_url: options.cancelUrl,
37
+ metadata: {
38
+ tenantId: options.tenantId,
39
+ ...(options.consentId && { consentId: options.consentId }),
40
+ },
41
+ ...(options.locale && {
42
+ customization: { force_language: forcedLanguage(options.locale) },
43
+ }),
44
+ },
45
+ }));
46
+ if (!response.checkout_url) {
47
+ throw new Error("subscription-dodo: checkout session was created without a checkout_url");
48
+ }
49
+ return { url: response.checkout_url };
50
+ };
51
+ }
52
+ const portalResponseSchema = z.object({ link: z.string() });
53
+ export function createDodoPortalSession(runtime) {
54
+ return async (ctx, options) => {
55
+ const response = portalResponseSchema.parse(await runtime.call(ctx, {
56
+ method: "POST",
57
+ path: `/customers/${encodeURIComponent(options.providerCustomerId)}/customer-portal/session`,
58
+ query: { return_url: options.returnUrl },
59
+ }));
60
+ return { url: response.link };
61
+ };
62
+ }
63
+ export function createDodoCancelSubscription(runtime) {
64
+ return async (ctx, options) => {
65
+ await runtime.call(ctx, {
66
+ method: "PATCH",
67
+ path: subscriptionPath(options.providerSubscriptionId),
68
+ body: options.when === SubscriptionCancelTimings.periodEnd
69
+ ? { cancel_at_next_billing_date: true }
70
+ : { status: "cancelled" },
71
+ });
72
+ };
73
+ }
74
+ export function createDodoResumeSubscription(runtime) {
75
+ return async (ctx, options) => {
76
+ await runtime.call(ctx, {
77
+ method: "PATCH",
78
+ path: subscriptionPath(options.providerSubscriptionId),
79
+ body: { cancel_at_next_billing_date: false },
80
+ });
81
+ };
82
+ }
83
+ // =============================================================================
84
+ // Prices
85
+ // =============================================================================
86
+ const productSchema = z.object({
87
+ product_id: z.string(),
88
+ price: z.discriminatedUnion("type", [
89
+ z.object({
90
+ type: z.literal("recurring_price"),
91
+ price: z.number(),
92
+ currency: z.string(),
93
+ payment_frequency_interval: z.string(),
94
+ payment_frequency_count: z.number(),
95
+ }),
96
+ z.object({
97
+ type: z.literal("one_time_price"),
98
+ price: z.number(),
99
+ currency: z.string(),
100
+ }),
101
+ z.object({ type: z.literal("usage_based_price") }),
102
+ ]),
103
+ // nullish: an older product without metadata must not fail the whole price lookup.
104
+ metadata: z.record(z.string(), z.string()).nullish(),
105
+ });
106
+ function mapDodoProduct(product) {
107
+ const { price } = product;
108
+ if (price.type === "usage_based_price")
109
+ return null;
110
+ const base = {
111
+ priceId: product.product_id,
112
+ unitAmount: price.price,
113
+ currency: price.currency.toLowerCase(),
114
+ active: true,
115
+ metadata: product.metadata ?? {},
116
+ };
117
+ if (price.type === "one_time_price")
118
+ return { ...base, interval: null, intervalCount: 1 };
119
+ const rawInterval = price.payment_frequency_interval.toLowerCase();
120
+ const interval = KNOWN_RECURRING_INTERVALS.find((known) => known === rawInterval) ?? null;
121
+ return {
122
+ ...base,
123
+ interval,
124
+ intervalCount: interval ? price.payment_frequency_count : null,
125
+ };
126
+ }
127
+ // Keyed by product id alone: ids are unique per account and a cache hit must not
128
+ // trigger an audited secret read on every billing-plans render. Failed lookups
129
+ // are never cached. `null` is a cached "skipped" product (usage-based).
130
+ export function createDodoPriceCache({ ttlMs = 600_000, now = () => Temporal.Now.instant().epochMilliseconds, } = {}) {
131
+ const store = new Map();
132
+ return {
133
+ get: (priceId) => {
134
+ const entry = store.get(priceId);
135
+ if (!entry)
136
+ return undefined;
137
+ if (entry.expiresAt <= now()) {
138
+ store.delete(priceId);
139
+ return undefined;
140
+ }
141
+ return entry.price;
142
+ },
143
+ set: (priceId, price) => {
144
+ store.set(priceId, { price, expiresAt: now() + ttlMs });
145
+ },
146
+ };
147
+ }
148
+ async function fillPriceCache(runtime, cache, ctx, priceIds) {
149
+ const misses = priceIds.filter((priceId) => cache.get(priceId) === undefined);
150
+ const settled = await Promise.allSettled(misses.map(async (priceId) => ({
151
+ priceId,
152
+ product: productSchema.parse(await runtime.call(ctx, {
153
+ method: "GET",
154
+ path: `/products/${encodeURIComponent(priceId)}`,
155
+ })),
156
+ })));
157
+ for (const outcome of settled) {
158
+ if (outcome.status === "fulfilled") {
159
+ cache.set(outcome.value.priceId, mapDodoProduct(outcome.value.product));
160
+ }
161
+ else {
162
+ const message = outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
163
+ ctx.log?.warn("subscription-dodo: product lookup failed", { message });
164
+ }
165
+ }
166
+ }
167
+ export function createDodoRetrievePrices(runtime, cache) {
168
+ return async (ctx, priceIds) => {
169
+ await fillPriceCache(runtime, cache, ctx, priceIds);
170
+ return priceIds.flatMap((priceId) => cache.get(priceId) ?? []);
171
+ };
172
+ }
173
+ // =============================================================================
174
+ // Plan switch (in-app: preview + apply)
175
+ // =============================================================================
176
+ const previewResponseSchema = z.object({
177
+ immediate_charge: z.object({
178
+ summary: z.object({
179
+ total_amount: z.number(),
180
+ tax: z.number().nullable(),
181
+ currency: z.string(),
182
+ customer_credits: z.number(),
183
+ }),
184
+ effective_at: z.string(),
185
+ }),
186
+ });
187
+ function changePlanBody(targetPriceId) {
188
+ return {
189
+ product_id: targetPriceId,
190
+ quantity: 1,
191
+ proration_billing_mode: "prorated_immediately",
192
+ effective_at: "immediately",
193
+ };
194
+ }
195
+ export function createDodoPreviewPlanSwitch(runtime) {
196
+ return async (ctx, options) => {
197
+ const { immediate_charge: charge } = previewResponseSchema.parse(await runtime.call(ctx, {
198
+ method: "POST",
199
+ path: `${subscriptionPath(options.providerSubscriptionId)}/change-plan/preview`,
200
+ body: changePlanBody(options.targetPriceId),
201
+ }));
202
+ return {
203
+ amountDueNow: charge.summary.total_amount,
204
+ tax: charge.summary.tax,
205
+ currency: charge.summary.currency.toLowerCase(),
206
+ credit: charge.summary.customer_credits,
207
+ effectiveAt: charge.effective_at,
208
+ };
209
+ };
210
+ }
211
+ const applyResponseSchema = z.looseObject({
212
+ payment_id: z.string().nullish(),
213
+ payment_link: z.string().nullish(),
214
+ });
215
+ // The switch itself arrives later as a subscription.plan_changed webhook;
216
+ // prevent_change keeps the old plan if the immediate charge fails.
217
+ export function createDodoApplyPlanSwitch(runtime) {
218
+ return async (ctx, options) => {
219
+ applyResponseSchema.parse(await runtime.call(ctx, {
220
+ method: "POST",
221
+ path: `${subscriptionPath(options.providerSubscriptionId)}/change-plan`,
222
+ body: {
223
+ ...changePlanBody(options.targetPriceId),
224
+ on_payment_failure: "prevent_change",
225
+ metadata: {
226
+ tenantId: options.tenantId,
227
+ ...(options.consentId && { consentId: options.consentId }),
228
+ },
229
+ },
230
+ }));
231
+ };
232
+ }
233
+ // =============================================================================
234
+ // retrieveSubscription
235
+ // =============================================================================
236
+ export function createDodoRetrieveSubscription(runtime, options) {
237
+ return async (ctx, providerSubscriptionId) => {
238
+ let raw;
239
+ try {
240
+ raw = await runtime.call(ctx, {
241
+ method: "GET",
242
+ path: subscriptionPath(providerSubscriptionId),
243
+ });
244
+ }
245
+ catch (error) {
246
+ if (error instanceof DodoHttpError && error.status === NOT_FOUND)
247
+ return null;
248
+ throw error;
249
+ }
250
+ const sub = dodoSubscriptionSchema.parse(raw);
251
+ const state = mapDodoSubscriptionState(sub, options.priceToTier);
252
+ if (!state)
253
+ return null;
254
+ return {
255
+ providerCustomerId: sub.customer.customer_id,
256
+ providerSubscriptionId: sub.subscription_id,
257
+ ...state,
258
+ };
259
+ };
260
+ }
@@ -0,0 +1,27 @@
1
+ import { type ConfigKeyHandle, type HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
3
+ import { type DodoRequest, type DodoSend } from "./client.ts";
4
+ export type DodoRuntimeDeps = {
5
+ readonly apiKeyHandle: ConfigKeyHandle<"text">;
6
+ readonly webhookSecretHandle: ConfigKeyHandle<"text">;
7
+ readonly billingLiveHandle: ConfigKeyHandle<"boolean">;
8
+ readonly environmentHandle: ConfigKeyHandle<"select">;
9
+ readonly send: DodoSend;
10
+ readonly fallback: {
11
+ readonly apiKey?: string;
12
+ readonly webhookSecret?: string;
13
+ };
14
+ };
15
+ export type DodoCtxRuntime = {
16
+ readonly call: (ctx: HandlerContext, request: DodoRequest) => Promise<unknown>;
17
+ readonly assertBillingLive: (ctx: HandlerContext, handlerName?: string) => Promise<void>;
18
+ readonly isBillingEnabled: (ctx: HandlerContext) => Promise<boolean>;
19
+ };
20
+ export type DodoWebhookRuntime = {
21
+ readonly resolveWebhookSecret: (systemSecrets?: SecretsContext) => Promise<string>;
22
+ };
23
+ export type DodoRuntimes = {
24
+ readonly ctx: DodoCtxRuntime;
25
+ readonly webhook: DodoWebhookRuntime;
26
+ };
27
+ export declare function createDodoRuntimes(deps: DodoRuntimeDeps): DodoRuntimes;
@@ -0,0 +1,89 @@
1
+ // Call-time key/flag resolution for the subscription-dodo plugin, mirroring
2
+ // subscription-stripe/runtime.ts: credentials come from backing:"secrets"
3
+ // config keys under SYSTEM_TENANT_ID, so rotation and go-live need no redeploy.
4
+ // The factory options survive as env-bridge fallbacks.
5
+ //
6
+ // Two read paths: post-tenant handlers read the key audited through the
7
+ // HandlerContext; the pre-tenant webhook reads the secret un-audited from the
8
+ // system SecretsContext the foundation passes in.
9
+ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
10
+ import { SYSTEM_TENANT_ID, } from "@cosmicdrift/kumiko-framework/engine";
11
+ import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
12
+ import { parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
13
+ import { createDodoHttp } from "./client.js";
14
+ import { DODO_BASE_URLS, DodoEnvironments, SUBSCRIPTION_DODO_FEATURE } from "./constants.js";
15
+ const API_KEY_HINT = "Set the Dodo Payments API key via config:write:set on `subscription-dodo:config:api-key` (or seed it from DODO_PAYMENTS_API_KEY during the env bridge).";
16
+ // backing:"secrets" values are stored JSON-serialized, so the store read has to
17
+ // be parsed back; the env fallback is a plain string and stays raw. The error
18
+ // must not carry the input: the JSON parser's own message would repeat the
19
+ // credential into logs and, for webhooks, into the 401 body.
20
+ function parseStoredSecret(raw) {
21
+ if (raw === undefined || raw.length === 0)
22
+ return undefined;
23
+ const parsed = parseJsonSafe(raw, undefined);
24
+ if (parsed === undefined) {
25
+ throw new Error("subscription-dodo credential (backing:secrets) is not valid JSON");
26
+ }
27
+ return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
28
+ }
29
+ export function createDodoRuntimes(deps) {
30
+ const http = createDodoHttp(deps.send);
31
+ async function ctxApiKey(ctx) {
32
+ let key;
33
+ if (ctx.secrets) {
34
+ const got = await requireSecretsContext(ctx, SUBSCRIPTION_DODO_FEATURE).get(SYSTEM_TENANT_ID, deps.apiKeyHandle.name);
35
+ key = parseStoredSecret(got?.reveal());
36
+ }
37
+ if (!key && deps.fallback.apiKey && deps.fallback.apiKey.length > 0) {
38
+ key = deps.fallback.apiKey;
39
+ }
40
+ if (!key) {
41
+ throw new UnconfiguredError({
42
+ feature: SUBSCRIPTION_DODO_FEATURE,
43
+ key: deps.apiKeyHandle.name,
44
+ hint: API_KEY_HINT,
45
+ });
46
+ }
47
+ return key;
48
+ }
49
+ async function baseUrlFor(ctx) {
50
+ const environment = ctx.config ? await ctx.config(deps.environmentHandle) : undefined;
51
+ return DODO_BASE_URLS[environment === DodoEnvironments.live ? "live" : "test"];
52
+ }
53
+ return {
54
+ ctx: {
55
+ call: async (ctx, request) => http({ baseUrl: await baseUrlFor(ctx), apiKey: await ctxApiKey(ctx) }, request),
56
+ assertBillingLive: async (ctx, handlerName = "create-checkout-session") => {
57
+ const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
58
+ if (live !== true)
59
+ throw new FeatureDisabledError(SUBSCRIPTION_DODO_FEATURE, handlerName);
60
+ },
61
+ isBillingEnabled: async (ctx) => {
62
+ const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
63
+ if (live !== true)
64
+ return false;
65
+ if (deps.fallback.apiKey !== undefined && deps.fallback.apiKey.length > 0)
66
+ return true;
67
+ // Existence-only probe on every billing-plans render: must stay un-audited.
68
+ return ctx.secrets
69
+ ? await ctx.secrets.has(SYSTEM_TENANT_ID, deps.apiKeyHandle.name)
70
+ : false;
71
+ },
72
+ },
73
+ webhook: {
74
+ resolveWebhookSecret: async (systemSecrets) => {
75
+ if (systemSecrets) {
76
+ // No auditCtx: every webhook would otherwise spam the audit trail.
77
+ const got = await systemSecrets.get(SYSTEM_TENANT_ID, deps.webhookSecretHandle.name);
78
+ const stored = parseStoredSecret(got?.reveal());
79
+ if (stored)
80
+ return stored;
81
+ }
82
+ const fallback = deps.fallback.webhookSecret;
83
+ if (fallback && fallback.length > 0)
84
+ return fallback;
85
+ throw new Error(`subscription-dodo: '${deps.webhookSecretHandle.name}' unresolved — no system-secret set and no factory fallback. Webhook cannot verify until it is configured.`);
86
+ },
87
+ },
88
+ };
89
+ }