@cosmicdrift/kumiko-samples 0.392.2 → 0.394.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 (186) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +15 -5
  3. package/packages/bundled-features/src/account-security/README.md +1 -1
  4. package/packages/bundled-features/src/account-security/changes.json +6 -0
  5. package/packages/bundled-features/src/account-security/feature.ts +9 -1
  6. package/packages/bundled-features/src/audit/event-classification.ts +1 -0
  7. package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
  8. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
  9. package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
  10. package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
  11. package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
  12. package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
  13. package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
  14. package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
  15. package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
  16. package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
  17. package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
  18. package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
  19. package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
  20. package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
  21. package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
  22. package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
  23. package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
  24. package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
  25. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  26. package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
  27. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
  28. package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
  29. package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
  30. package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
  31. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  32. package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
  33. package/packages/bundled-features/src/cap-overview/types.ts +2 -0
  34. package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
  35. package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
  36. package/packages/bundled-features/src/files/changes.json +18 -0
  37. package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
  38. package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
  39. package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
  40. package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
  41. package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
  42. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +155 -0
  43. package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
  44. package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
  45. package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
  46. package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
  47. package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
  48. package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
  49. package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
  50. package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
  51. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
  52. package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
  53. package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
  54. package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
  55. package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
  56. package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
  57. package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
  58. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
  59. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
  60. package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
  61. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
  62. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
  63. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
  64. package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
  65. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
  66. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
  67. package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
  68. package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
  71. package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
  75. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -1
  76. package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
  77. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +94 -15
  78. package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
  79. package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
  80. package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
  81. package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
  82. package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
  83. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
  84. package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
  85. package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
  86. package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
  87. package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
  88. package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
  89. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
  90. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
  91. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
  92. package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
  93. package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
  94. package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
  95. package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
  96. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +239 -79
  97. package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
  98. package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
  99. package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
  100. package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
  101. package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
  102. package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
  103. package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
  104. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
  105. package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
  106. package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
  107. package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
  108. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
  109. package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
  110. package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
  111. package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
  112. package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
  113. package/packages/bundled-features/src/inbound-provider-imap/changes.json +42 -0
  114. package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +155 -63
  115. package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
  116. package/packages/bundled-features/src/inbound-provider-imap/constants.ts +14 -1
  117. package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
  118. package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
  119. package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -44
  120. package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
  121. package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +28 -2
  122. package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
  123. package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
  124. package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
  125. package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
  126. package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +45 -0
  127. package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +103 -0
  128. package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +49 -0
  129. package/packages/bundled-features/src/inbound-provider-imap/screens.ts +209 -42
  130. package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
  131. package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
  132. package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
  133. package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
  134. package/packages/bundled-features/src/jobs/changes.json +18 -0
  135. package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
  136. package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
  137. package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
  138. package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
  139. package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
  140. package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
  141. package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
  142. package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
  143. package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
  144. package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
  145. package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
  146. package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
  147. package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
  148. package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
  149. package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
  150. package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
  151. package/packages/bundled-features/src/template-resolver/changes.json +12 -0
  152. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
  153. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
  154. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
  155. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
  156. package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
  157. package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
  158. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
  159. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
  160. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
  161. package/samples/apps/use-all-bundled/feature-manifest.json +89 -39
  162. package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
  163. package/samples/apps/use-all-bundled/package.json +1 -0
  164. package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
  165. package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
  166. package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
  167. package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
  168. package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
  169. package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
  170. package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
  171. package/samples/recipes/cross-feature-events/README.md +10 -0
  172. package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
  173. package/samples/recipes/dashboard-stats/README.md +8 -0
  174. package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
  175. package/samples/recipes/env-composition/README.md +2 -2
  176. package/samples/recipes/env-composition/src/env.ts +2 -2
  177. package/samples/recipes/inbound-mail-triage/README.md +12 -0
  178. package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
  179. package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
  180. package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
  181. package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
  182. package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
  183. package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
  184. package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
  185. package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
  186. package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
@@ -0,0 +1,265 @@
1
+ // kumiko-feature-version: 1
2
+ //
3
+ // subscription-paddle — Paddle Billing (merchant of record) plugin for the
4
+ // billing-foundation plugin API. Credentials, the billing-live master switch and
5
+ // the API environment (sandbox | live) are config keys resolved at call time, like
6
+ // subscription-stripe. The `apiKey` / `webhookSecret` / `tenantBindingKey` /
7
+ // `clientToken` options are env→config bridge fallbacks.
8
+
9
+ import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
10
+ import {
11
+ access,
12
+ createSystemConfig,
13
+ defineFeature,
14
+ type FeatureDefinition,
15
+ } from "@cosmicdrift/kumiko-framework/engine";
16
+ import { type FeatureWithEnvSchema, featureWithEnvSchema } from "@cosmicdrift/kumiko-framework/env";
17
+ import { egress } from "@cosmicdrift/kumiko-framework/http";
18
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
19
+ import * as z from "zod";
20
+ import { makeCheckoutConfigQuery } from "./checkout-config.query.ts";
21
+ import {
22
+ createPaddleCancelSubscription,
23
+ createPaddleCheckoutSession,
24
+ createPaddlePortalSession,
25
+ createPaddleResumeSubscription,
26
+ } from "./checkout-methods.ts";
27
+ import { servePaddleCheckoutPage } from "./checkout-page.ts";
28
+ import type { PaddleSend } from "./client.ts";
29
+ import {
30
+ PADDLE_API_KEY_CONFIG,
31
+ PADDLE_BILLING_LIVE_CONFIG,
32
+ PADDLE_CLIENT_TOKEN_CONFIG,
33
+ PADDLE_ENVIRONMENT_CONFIG,
34
+ PADDLE_PROVIDER_NAME,
35
+ PADDLE_TENANT_BINDING_KEY_CONFIG,
36
+ PADDLE_WEBHOOK_SECRET_CONFIG,
37
+ PaddleEnvironments,
38
+ SUBSCRIPTION_PADDLE_FEATURE,
39
+ } from "./constants.ts";
40
+ import {
41
+ createPaddleApplyPlanSwitch,
42
+ createPaddlePreviewPlanSwitch,
43
+ createPaddleRetrieveSubscription,
44
+ } from "./plan-switch-methods.ts";
45
+ import { createPaddlePriceCache, createPaddleRetrievePrices } from "./price-methods.ts";
46
+ import { createPaddleRuntimes } from "./runtime.ts";
47
+ import { verifyAndParsePaddleWebhook } from "./webhook-events.ts";
48
+
49
+ /** Bridge contract: all fields are optional fallbacks for the config store. */
50
+ export const subscriptionPaddleEnvSchema = z.object({
51
+ PADDLE_API_KEY: z
52
+ .string()
53
+ .min(1)
54
+ .describe("Paddle API key. Bridge-fallback — prefer the secret.")
55
+ .meta({ kumiko: { pulumi: { secret: true } } })
56
+ .optional(),
57
+ PADDLE_WEBHOOK_SECRET: z
58
+ .string()
59
+ .min(1)
60
+ .describe("Paddle notification secret (`pdl_ntfset_...`). Bridge-fallback — prefer the secret.")
61
+ .meta({ kumiko: { pulumi: { secret: true } } })
62
+ .optional(),
63
+ PADDLE_TENANT_BINDING_KEY: z
64
+ .string()
65
+ .min(1)
66
+ .describe(
67
+ "Server-only HMAC key binding a Paddle checkout to its tenant. Bridge-fallback — prefer the secret.",
68
+ )
69
+ .meta({ kumiko: { pulumi: { secret: true } } })
70
+ .optional(),
71
+ PADDLE_CLIENT_TOKEN: z
72
+ .string()
73
+ .regex(/^(test|live)_/, "PADDLE_CLIENT_TOKEN must start with 'test_' or 'live_'")
74
+ .describe("Paddle.js client-side token (public). Bridge-fallback for the config key.")
75
+ .optional(),
76
+ });
77
+
78
+ type PaddlePriceEnvShape<TPrice extends string> = {
79
+ [Key in `PADDLE_PRICE_${TPrice}`]: z.ZodOptional<z.ZodString>;
80
+ };
81
+
82
+ /** Paddle env block plus one `PADDLE_PRICE_<NAME>` slot per plan price the app
83
+ * maps to a tier, e.g. `paddleEnvSchema({ prices: ["PRO", "TEAM"] })`.
84
+ * Shares its bridge fields with `subscriptionPaddleEnvSchema`, so spreading
85
+ * it into `composeEnvSchema({ extend })` next to the mounted feature is no
86
+ * key clash. */
87
+ export function paddleEnvSchema<const TPrice extends string>(options: {
88
+ readonly prices: readonly TPrice[];
89
+ }) {
90
+ const priceShape = Object.fromEntries(
91
+ options.prices.map((price) => [
92
+ `PADDLE_PRICE_${price}`,
93
+ z
94
+ .string()
95
+ .regex(/^pri_/, `PADDLE_PRICE_${price} must start with 'pri_'`)
96
+ .optional()
97
+ .describe(`Paddle price id (\`pri_...\`) of the ${price} plan.`),
98
+ ]),
99
+ // @cast-boundary schema-walk — Object.fromEntries widens to an index signature; the entries are exactly one optional string per listed price.
100
+ ) as PaddlePriceEnvShape<TPrice>;
101
+ return z.object({ ...subscriptionPaddleEnvSchema.shape, ...priceShape });
102
+ }
103
+
104
+ export type SubscriptionPaddleOptions = {
105
+ /** Bridge-fallback API key; the `api-key` config key wins when set. */
106
+ readonly apiKey?: string;
107
+ /** Bridge-fallback webhook secret; the `webhook-secret` config key wins when set. */
108
+ readonly webhookSecret?: string;
109
+ /** Bridge-fallback tenant binding key; the `tenant-binding-key` config key wins when set. */
110
+ readonly tenantBindingKey?: string;
111
+ /** Bridge-fallback Paddle.js client token; the `client-token` config key wins when set. */
112
+ readonly clientToken?: string;
113
+ /** Paddle price id → app tier name. Prices absent here drop the event. */
114
+ readonly priceToTier?: Readonly<Record<string, string>>;
115
+ /** Price ids a mode:"payment" checkout may target; empty = one-off checkouts closed. */
116
+ readonly oneOffPriceIds?: readonly string[];
117
+ /** HTTP transport; defaults to the SSRF-safe external egress. Test seam. */
118
+ readonly send?: PaddleSend;
119
+ /** Epoch milliseconds for the plan-switch preview timestamp. Test seam. */
120
+ readonly now?: () => number;
121
+ /** Unix seconds for the webhook tolerance window. Test seam. */
122
+ readonly nowSeconds?: () => number;
123
+ };
124
+
125
+ // Validated once at feature-build time so a typo fails app startup instead of
126
+ // silently widening or narrowing the one-off allowlist at request time.
127
+ function validateOneOffPriceIds(priceIds: readonly string[]): void {
128
+ const seen = new Set<string>();
129
+ for (const priceId of priceIds) {
130
+ if (priceId.length === 0) {
131
+ throw new Error("subscription-paddle: oneOffPriceIds must not contain an empty string");
132
+ }
133
+ if (seen.has(priceId)) {
134
+ throw new Error(`subscription-paddle: duplicate oneOffPriceIds entry "${priceId}"`);
135
+ }
136
+ seen.add(priceId);
137
+ }
138
+ }
139
+
140
+ export function createSubscriptionPaddleFeature(
141
+ options: SubscriptionPaddleOptions = {},
142
+ ): FeatureWithEnvSchema<FeatureDefinition, typeof subscriptionPaddleEnvSchema.shape> {
143
+ validateOneOffPriceIds(options.oneOffPriceIds ?? []);
144
+ const now = options.now ?? (() => Temporal.Now.instant().epochMilliseconds);
145
+ const feature: FeatureDefinition = defineFeature(SUBSCRIPTION_PADDLE_FEATURE, (r) => {
146
+ r.describe(
147
+ 'Paddle Billing provider plugin for `billing-foundation`. Paddle 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, webhook secret and tenant binding key from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant), the public Paddle.js `client-token`, a `billingLive` master switch and an `environment` key (sandbox | live). `environment` is its own key because Paddle serves sandbox and live from different API hosts and a live key must never hit the sandbox host or the other way round; `billingLive` only decides whether checkouts are offered at all. All of them 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 `createSubscriptionPaddleFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret`/`tenantBindingKey`/`clientToken` options are env to config bridge fallbacks (`PADDLE_API_KEY`, `PADDLE_WEBHOOK_SECRET`, `PADDLE_TENANT_BINDING_KEY`, `PADDLE_CLIENT_TOKEN`). The plugin always mounts; `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, and billing counts as enabled only with API key and binding key present. Webhooks arrive at `/webhooks/subscription/paddle` when mounted through the server-runtime `billing.paddle` boot option, or at `/api/subscription/webhook/paddle` when `createSubscriptionWebhookRoute` is mounted by hand and are verified with the `Paddle-Signature` header (HMAC-SHA256 over `ts:rawBody` keyed with the raw secret, any `h1` may match, 5 minute tolerance). Paddle.js can open checkouts with arbitrary `customData` from the browser, so `custom_data.tenantId` is only trusted together with `tenantBinding`, an HMAC of the tenant id under the binding key; events without a valid binding, for prices outside `priceToTier` or without a tenant are dropped. Rotating the binding key makes the webhooks of existing subscriptions fail that check until they are re-bound. Paddle has no hosted checkout page, so the feature serves the Default Payment Link itself at `/billing/paddle-checkout` (the Default Payment Link in the Paddle dashboard must point to `<APP_BASE_URL>/billing/paddle-checkout`; it is the only source, because checkouts are created without a `checkout.url`). Create subscription prices in the Paddle catalog with quantity minimum and maximum 1; for one-off packs set the quantity limits on purpose, because the payment event carries `quantity` and the app has to multiply its credits by it: an anonymous page with its own CSP that loads Paddle.js from cdn.paddle.com, the one external script this framework allows, and opens the overlay for the transaction. Implements checkout, customer portal, cancel and resume, `retrievePrices` (batched, 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 `subscription.updated` webhook to move the tier.',
148
+ );
149
+ r.uiHints({
150
+ displayLabel: "Billing · Paddle",
151
+ category: "billing",
152
+ recommended: false,
153
+ });
154
+ r.requires({ features: ["billing-foundation"] });
155
+ r.requires({ features: ["config"] });
156
+ r.requires({ features: ["secrets"] });
157
+ r.envSchema(subscriptionPaddleEnvSchema);
158
+
159
+ const configKeys = r.config({
160
+ keys: {
161
+ [PADDLE_API_KEY_CONFIG]: createSystemConfig("text", {
162
+ backing: "secrets",
163
+ write: access.systemAdmin,
164
+ read: access.admin,
165
+ mask: { title: "subscription-paddle.api-key", icon: "key", order: 1 },
166
+ }),
167
+ [PADDLE_WEBHOOK_SECRET_CONFIG]: createSystemConfig("text", {
168
+ backing: "secrets",
169
+ write: access.systemAdmin,
170
+ read: access.admin,
171
+ mask: { title: "subscription-paddle.webhook-secret", icon: "shield", order: 2 },
172
+ }),
173
+ [PADDLE_TENANT_BINDING_KEY_CONFIG]: createSystemConfig("text", {
174
+ backing: "secrets",
175
+ write: access.systemAdmin,
176
+ read: access.admin,
177
+ mask: { title: "subscription-paddle.tenant-binding-key", icon: "lock", order: 3 },
178
+ }),
179
+ [PADDLE_CLIENT_TOKEN_CONFIG]: createSystemConfig("text", {
180
+ write: access.systemAdmin,
181
+ read: access.admin,
182
+ mask: { title: "subscription-paddle.client-token", icon: "settings", order: 4 },
183
+ }),
184
+ [PADDLE_ENVIRONMENT_CONFIG]: createSystemConfig("select", {
185
+ default: PaddleEnvironments.sandbox,
186
+ options: [PaddleEnvironments.sandbox, PaddleEnvironments.live],
187
+ write: access.systemAdmin,
188
+ read: access.admin,
189
+ mask: { title: "subscription-paddle.environment", icon: "server", order: 5 },
190
+ }),
191
+ [PADDLE_BILLING_LIVE_CONFIG]: createSystemConfig("boolean", {
192
+ default: false,
193
+ write: access.privileged,
194
+ read: access.admin,
195
+ mask: { title: "subscription-paddle.billing-live", icon: "rocket", order: 6 },
196
+ }),
197
+ },
198
+ });
199
+
200
+ r.translations({
201
+ keys: {
202
+ "subscription-paddle.api-key": { en: "Paddle API key" },
203
+ "subscription-paddle.webhook-secret": { en: "Paddle webhook secret" },
204
+ "subscription-paddle.tenant-binding-key": { en: "Paddle tenant binding key" },
205
+ "subscription-paddle.client-token": { en: "Paddle client-side token" },
206
+ "subscription-paddle.environment": { en: "Paddle environment" },
207
+ "subscription-paddle.billing-live": { en: "Live billing" },
208
+ "subscription-paddle.settings": { en: "Paddle" },
209
+ "screen:subscription-paddle-system.title": { en: "Paddle" },
210
+ },
211
+ });
212
+
213
+ const priceToTier = options.priceToTier ?? {};
214
+ const runtimes = createPaddleRuntimes({
215
+ apiKeyHandle: configKeys[PADDLE_API_KEY_CONFIG],
216
+ webhookSecretHandle: configKeys[PADDLE_WEBHOOK_SECRET_CONFIG],
217
+ tenantBindingKeyHandle: configKeys[PADDLE_TENANT_BINDING_KEY_CONFIG],
218
+ clientTokenHandle: configKeys[PADDLE_CLIENT_TOKEN_CONFIG],
219
+ billingLiveHandle: configKeys[PADDLE_BILLING_LIVE_CONFIG],
220
+ environmentHandle: configKeys[PADDLE_ENVIRONMENT_CONFIG],
221
+ send: options.send ?? egress({ kind: "external" }),
222
+ fallback: {
223
+ ...(options.apiKey !== undefined && { apiKey: options.apiKey }),
224
+ ...(options.webhookSecret !== undefined && { webhookSecret: options.webhookSecret }),
225
+ ...(options.tenantBindingKey !== undefined && {
226
+ tenantBindingKey: options.tenantBindingKey,
227
+ }),
228
+ ...(options.clientToken !== undefined && { clientToken: options.clientToken }),
229
+ },
230
+ });
231
+
232
+ r.queryHandler(makeCheckoutConfigQuery(runtimes.readCheckoutConfig));
233
+ r.httpRoute({
234
+ method: "GET",
235
+ // Literal for the feature-AST extractor; must equal PADDLE_CHECKOUT_PATH (integration test).
236
+ path: "/billing/paddle-checkout",
237
+ anonymous: true,
238
+ rateLimit: { per: "ip+handler", limit: 60, windowSeconds: 60 },
239
+ handler: (c, deps) => servePaddleCheckoutPage(c, deps),
240
+ });
241
+
242
+ const plugin: SubscriptionProviderPlugin = {
243
+ displayName: "Paddle",
244
+ verifyAndParseWebhook: verifyAndParsePaddleWebhook(runtimes.webhook, {
245
+ priceToTier,
246
+ ...(options.nowSeconds && { nowSeconds: options.nowSeconds }),
247
+ }),
248
+ createCheckoutSession: createPaddleCheckoutSession(runtimes.ctx),
249
+ createPortalSession: createPaddlePortalSession(runtimes.ctx),
250
+ cancelSubscription: createPaddleCancelSubscription(runtimes.ctx),
251
+ resumeSubscription: createPaddleResumeSubscription(runtimes.ctx),
252
+ priceToTier,
253
+ oneOffPriceIds: options.oneOffPriceIds ?? [],
254
+ isBillingEnabled: runtimes.ctx.isBillingEnabled,
255
+ retrievePrices: createPaddleRetrievePrices(runtimes.ctx, createPaddlePriceCache({ now })),
256
+ retrieveSubscription: createPaddleRetrieveSubscription(runtimes.ctx, { priceToTier }),
257
+ previewPlanSwitch: createPaddlePreviewPlanSwitch(runtimes.ctx, now),
258
+ applyPlanSwitch: createPaddleApplyPlanSwitch(runtimes.ctx),
259
+ };
260
+ r.useExtension("subscriptionProvider", PADDLE_PROVIDER_NAME, plugin);
261
+
262
+ return { configKeys };
263
+ });
264
+ return featureWithEnvSchema(feature, subscriptionPaddleEnvSchema);
265
+ }
@@ -0,0 +1,17 @@
1
+ // Public API of the subscription-paddle bundled feature. The webhook verifier,
2
+ // client and plugin methods are internal to feature.ts.
3
+
4
+ export {
5
+ PADDLE_BASE_URLS,
6
+ PADDLE_CHECKOUT_PATH,
7
+ PADDLE_PROVIDER_NAME,
8
+ type PaddleEnvironment,
9
+ PaddleEnvironments,
10
+ SUBSCRIPTION_PADDLE_FEATURE,
11
+ } from "./constants.ts";
12
+ export {
13
+ createSubscriptionPaddleFeature,
14
+ paddleEnvSchema,
15
+ type SubscriptionPaddleOptions,
16
+ subscriptionPaddleEnvSchema,
17
+ } from "./feature.ts";
@@ -0,0 +1,135 @@
1
+ // Paddle in-app plan switch (preview + apply) and retrieveSubscription.
2
+
3
+ import type {
4
+ PlanSwitchPreview,
5
+ ProviderSubscriptionSnapshot,
6
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
7
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
8
+ import * as z from "zod";
9
+ import { paddleSubscriptionPath } from "./checkout-methods.ts";
10
+ import { PaddleHttpError } from "./client.ts";
11
+ import { paddleAmountSchema } from "./price-methods.ts";
12
+ import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
13
+ import { mapPaddleSubscriptionState, paddleSubscriptionSchema } from "./subscription-state.ts";
14
+ import { signTenantBinding } from "./tenant-binding.ts";
15
+
16
+ const NOT_FOUND = 404;
17
+
18
+ function parseAmount(amount: string): number {
19
+ return Number.parseInt(amount, 10);
20
+ }
21
+
22
+ const previewResponseSchema = z.object({
23
+ data: z.object({
24
+ currency_code: z.string(),
25
+ immediate_transaction: z
26
+ .object({
27
+ details: z.object({
28
+ totals: z.object({
29
+ grand_total: paddleAmountSchema,
30
+ tax: paddleAmountSchema,
31
+ currency_code: z.string().nullish(),
32
+ }),
33
+ }),
34
+ })
35
+ .nullish(),
36
+ recurring_transaction_details: z
37
+ .object({
38
+ // `total` is the full period price incl. tax; `grand_total` is already
39
+ // reduced by credit, so it would understate the recurring price.
40
+ totals: z.object({
41
+ total: paddleAmountSchema.nullish(),
42
+ tax: paddleAmountSchema.nullish(),
43
+ }),
44
+ })
45
+ .nullish(),
46
+ update_summary: z.object({ credit: z.object({ amount: paddleAmountSchema }) }).nullish(),
47
+ }),
48
+ });
49
+
50
+ function changePlanBody(targetPriceId: string) {
51
+ return {
52
+ items: [{ price_id: targetPriceId, quantity: 1 }],
53
+ proration_billing_mode: "prorated_immediately",
54
+ };
55
+ }
56
+
57
+ export function createPaddlePreviewPlanSwitch(
58
+ runtime: PaddleCtxRuntime,
59
+ now: () => number,
60
+ ): PaddlePluginMethod<"previewPlanSwitch"> {
61
+ return async (ctx, options): Promise<PlanSwitchPreview> => {
62
+ const { data } = previewResponseSchema.parse(
63
+ await runtime.call(ctx, {
64
+ method: "PATCH",
65
+ path: `${paddleSubscriptionPath(options.providerSubscriptionId)}/preview`,
66
+ body: changePlanBody(options.targetPriceId),
67
+ }),
68
+ );
69
+ const totals = data.immediate_transaction?.details.totals;
70
+ const recurring = data.recurring_transaction_details?.totals;
71
+ return {
72
+ amountDueNow: totals ? parseAmount(totals.grand_total) : 0,
73
+ tax: totals ? parseAmount(totals.tax) : null,
74
+ currency: (totals?.currency_code ?? data.currency_code).toLowerCase(),
75
+ credit: data.update_summary ? parseAmount(data.update_summary.credit.amount) : 0,
76
+ ...(recurring?.total != null && {
77
+ recurringAmount: parseAmount(recurring.total),
78
+ recurringTax: recurring.tax != null ? parseAmount(recurring.tax) : null,
79
+ }),
80
+ effectiveAt: Temporal.Instant.fromEpochMilliseconds(now()).toString(),
81
+ };
82
+ };
83
+ }
84
+
85
+ // The switch itself arrives later as a subscription.updated webhook;
86
+ // prevent_change keeps the old plan if the immediate charge fails. PATCH
87
+ // replaces custom_data as a whole, so the tenant binding is sent again.
88
+ export function createPaddleApplyPlanSwitch(
89
+ runtime: PaddleCtxRuntime,
90
+ ): PaddlePluginMethod<"applyPlanSwitch"> {
91
+ return async (ctx, options) => {
92
+ const bindingKey = await runtime.tenantBindingKey(ctx);
93
+ await runtime.call(ctx, {
94
+ method: "PATCH",
95
+ path: paddleSubscriptionPath(options.providerSubscriptionId),
96
+ body: {
97
+ ...changePlanBody(options.targetPriceId),
98
+ on_payment_failure: "prevent_change",
99
+ custom_data: {
100
+ tenantId: options.tenantId,
101
+ tenantBinding: signTenantBinding(bindingKey, options.tenantId),
102
+ ...(options.consentId && { consentId: options.consentId }),
103
+ },
104
+ },
105
+ });
106
+ };
107
+ }
108
+
109
+ const subscriptionResponseSchema = z.object({ data: paddleSubscriptionSchema });
110
+
111
+ export function createPaddleRetrieveSubscription(
112
+ runtime: PaddleCtxRuntime,
113
+ options: { readonly priceToTier: Readonly<Record<string, string>> },
114
+ ): PaddlePluginMethod<"retrieveSubscription"> {
115
+ return async (ctx, providerSubscriptionId): Promise<ProviderSubscriptionSnapshot | null> => {
116
+ let raw: unknown;
117
+ try {
118
+ raw = await runtime.call(ctx, {
119
+ method: "GET",
120
+ path: paddleSubscriptionPath(providerSubscriptionId),
121
+ });
122
+ } catch (error) {
123
+ if (error instanceof PaddleHttpError && error.status === NOT_FOUND) return null;
124
+ throw error;
125
+ }
126
+ const { data: sub } = subscriptionResponseSchema.parse(raw);
127
+ const state = mapPaddleSubscriptionState(sub, options.priceToTier);
128
+ if (!state) return null;
129
+ return {
130
+ providerCustomerId: sub.customer_id,
131
+ providerSubscriptionId: sub.id,
132
+ ...state,
133
+ };
134
+ };
135
+ }
@@ -0,0 +1,148 @@
1
+ // Paddle price lookup for the billing-plans catalog: one batched request for all
2
+ // cache misses, cached per price id.
3
+
4
+ import {
5
+ KNOWN_RECURRING_INTERVALS,
6
+ type ProviderPrice,
7
+ } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
8
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
10
+ import * as z from "zod";
11
+ import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
12
+
13
+ const PRICES_PER_REQUEST = 200;
14
+
15
+ /** Paddle sends amounts as integer strings in the smallest currency unit. */
16
+ export const paddleAmountSchema = z.string().regex(/^-?\d+$/);
17
+
18
+ const customDataSchema = z.record(z.string(), z.unknown()).nullish();
19
+
20
+ const paddlePriceSchema = z.object({
21
+ id: z.string().min(1),
22
+ unit_price: z.object({ amount: paddleAmountSchema, currency_code: z.string() }),
23
+ billing_cycle: z.object({ interval: z.string(), frequency: z.number() }).nullable(),
24
+ status: z.string(),
25
+ custom_data: customDataSchema,
26
+ product: z.object({ custom_data: customDataSchema }).nullish(),
27
+ });
28
+ type PaddlePrice = z.infer<typeof paddlePriceSchema>;
29
+
30
+ const pricesResponseSchema = z.object({ data: z.array(paddlePriceSchema) });
31
+
32
+ function stringEntries(
33
+ customData: Readonly<Record<string, unknown>> | null | undefined,
34
+ ): Array<[string, string]> {
35
+ return Object.entries(customData ?? {}).flatMap(([key, value]): Array<[string, string]> => {
36
+ if (typeof value === "string") return [[key, value]];
37
+ if (typeof value === "number") return [[key, String(value)]];
38
+ return [];
39
+ });
40
+ }
41
+
42
+ // Price custom data wins over the product's.
43
+ function metadataOf(price: PaddlePrice): Record<string, string> {
44
+ return Object.fromEntries([
45
+ ...stringEntries(price.product?.custom_data),
46
+ ...stringEntries(price.custom_data),
47
+ ]);
48
+ }
49
+
50
+ function mapPaddlePrice(price: PaddlePrice): ProviderPrice {
51
+ const base = {
52
+ priceId: price.id,
53
+ unitAmount: Number.parseInt(price.unit_price.amount, 10),
54
+ currency: price.unit_price.currency_code.toLowerCase(),
55
+ active: price.status === "active",
56
+ metadata: metadataOf(price),
57
+ };
58
+ const cycle = price.billing_cycle;
59
+ if (!cycle) return { ...base, interval: null, intervalCount: 1 };
60
+ const interval = KNOWN_RECURRING_INTERVALS.find((known) => known === cycle.interval) ?? null;
61
+ return { ...base, interval, intervalCount: interval ? cycle.frequency : null };
62
+ }
63
+
64
+ export type PaddlePriceCache = {
65
+ readonly get: (priceId: string) => ProviderPrice | undefined;
66
+ readonly set: (priceId: string, price: ProviderPrice) => void;
67
+ };
68
+
69
+ export type PaddlePriceCacheOptions = {
70
+ readonly ttlMs?: number;
71
+ readonly now?: () => number;
72
+ };
73
+
74
+ // Keyed by price id alone: ids are unique per account and a cache hit must not
75
+ // trigger an audited secret read on every billing-plans render. Failed lookups
76
+ // are never cached.
77
+ export function createPaddlePriceCache({
78
+ ttlMs = 600_000,
79
+ now = () => Temporal.Now.instant().epochMilliseconds,
80
+ }: PaddlePriceCacheOptions = {}): PaddlePriceCache {
81
+ const store = new Map<string, { readonly price: ProviderPrice; readonly expiresAt: number }>();
82
+ return {
83
+ get: (priceId) => {
84
+ const entry = store.get(priceId);
85
+ if (!entry) return undefined;
86
+ if (entry.expiresAt <= now()) {
87
+ store.delete(priceId);
88
+ return undefined;
89
+ }
90
+ return entry.price;
91
+ },
92
+ set: (priceId, price) => {
93
+ store.set(priceId, { price, expiresAt: now() + ttlMs });
94
+ },
95
+ };
96
+ }
97
+
98
+ function chunked<T>(items: readonly T[], size: number): T[][] {
99
+ const chunks: T[][] = [];
100
+ for (let start = 0; start < items.length; start += size) {
101
+ chunks.push(items.slice(start, start + size));
102
+ }
103
+ return chunks;
104
+ }
105
+
106
+ async function fillPriceCache(
107
+ runtime: PaddleCtxRuntime,
108
+ cache: PaddlePriceCache,
109
+ ctx: HandlerContext,
110
+ priceIds: readonly string[],
111
+ ): Promise<void> {
112
+ const misses = [...new Set(priceIds.filter((priceId) => cache.get(priceId) === undefined))];
113
+ const settled = await Promise.allSettled(
114
+ chunked(misses, PRICES_PER_REQUEST).map(async (ids) =>
115
+ pricesResponseSchema.parse(
116
+ await runtime.call(ctx, {
117
+ method: "GET",
118
+ path: "/prices",
119
+ query: {
120
+ id: ids.join(","),
121
+ include: "product",
122
+ status: "active,archived",
123
+ per_page: String(PRICES_PER_REQUEST),
124
+ },
125
+ }),
126
+ ),
127
+ ),
128
+ );
129
+ for (const outcome of settled) {
130
+ if (outcome.status === "fulfilled") {
131
+ for (const price of outcome.value.data) cache.set(price.id, mapPaddlePrice(price));
132
+ } else {
133
+ const message =
134
+ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
135
+ ctx.obs.log?.warn("subscription-paddle: price lookup failed", { message });
136
+ }
137
+ }
138
+ }
139
+
140
+ export function createPaddleRetrievePrices(
141
+ runtime: PaddleCtxRuntime,
142
+ cache: PaddlePriceCache,
143
+ ): PaddlePluginMethod<"retrievePrices"> {
144
+ return async (ctx, priceIds) => {
145
+ await fillPriceCache(runtime, cache, ctx, priceIds);
146
+ return priceIds.flatMap((priceId) => cache.get(priceId) ?? []);
147
+ };
148
+ }