@cosmicdrift/kumiko-samples 0.393.0 → 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.
- package/package.json +1 -1
- package/packages/bundled-features/package.json +15 -5
- package/packages/bundled-features/src/account-security/README.md +1 -1
- package/packages/bundled-features/src/account-security/changes.json +6 -0
- package/packages/bundled-features/src/account-security/feature.ts +9 -1
- package/packages/bundled-features/src/audit/event-classification.ts +1 -0
- package/packages/bundled-features/src/auth-email-password/changes.json +6 -0
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +10 -52
- package/packages/bundled-features/src/auth-email-password/web/auth-gate.tsx +4 -4
- package/packages/bundled-features/src/auth-email-password/web/auth-redirect.ts +3 -12
- package/packages/bundled-features/src/auth-email-password/web/index.ts +2 -0
- package/packages/bundled-features/src/auth-email-password/web/login-failure.ts +54 -0
- package/packages/bundled-features/src/auth-email-password/web/login-url.ts +19 -0
- package/packages/bundled-features/src/auth-email-password/web/static-login.ts +183 -0
- package/packages/bundled-features/src/auth-mfa/web/mfa-client.ts +7 -63
- package/packages/bundled-features/src/billing-foundation/changes.json +30 -0
- package/packages/bundled-features/src/billing-foundation/checkout-core.ts +8 -16
- package/packages/bundled-features/src/billing-foundation/events.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/feature.ts +1 -1
- package/packages/bundled-features/src/billing-foundation/handlers/create-checkout-session.write.ts +9 -2
- package/packages/bundled-features/src/billing-foundation/handlers/process-payment-event.write.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/index.ts +2 -0
- package/packages/bundled-features/src/billing-foundation/one-off-checkout-gate.ts +44 -0
- package/packages/bundled-features/src/billing-foundation/one-off-customer.ts +17 -0
- package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
- package/packages/bundled-features/src/billing-foundation/types.ts +26 -0
- package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +1 -0
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-confirm-dialog.tsx +8 -2
- package/packages/bundled-features/src/billing-foundation/web/plan-switch-recurring.ts +23 -0
- package/packages/bundled-features/src/billing-foundation/webhook-handler.ts +1 -0
- package/packages/bundled-features/src/cap-overview/changes.json +6 -0
- package/packages/bundled-features/src/cap-overview/handlers/caps-usage.query.ts +3 -3
- package/packages/bundled-features/src/cap-overview/types.ts +2 -0
- package/packages/bundled-features/src/compliance-profiles/web/compliance-profile-catalog.tsx +10 -1
- package/packages/bundled-features/src/file-foundation/read-file-ref-content.ts +12 -3
- package/packages/bundled-features/src/files/changes.json +18 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-health.ts +146 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll-schedule.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-poll.ts +238 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-record.ts +2 -0
- package/packages/bundled-features/src/inbound-mail-foundation/account-wake.ts +21 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +143 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-callback-verify.ts +65 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-route-responses.ts +26 -0
- package/packages/bundled-features/src/inbound-mail-foundation/connect-routes.ts +93 -62
- package/packages/bundled-features/src/inbound-mail-foundation/constants.ts +66 -3
- package/packages/bundled-features/src/inbound-mail-foundation/content-refetch.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/describe-error.ts +5 -0
- package/packages/bundled-features/src/inbound-mail-foundation/entities.ts +45 -2
- package/packages/bundled-features/src/inbound-mail-foundation/events.ts +69 -2
- package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +133 -118
- package/packages/bundled-features/src/inbound-mail-foundation/file-storage-failure.ts +23 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/account-address.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/acknowledge-quarantine.write.ts +55 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/attach-message-content.write.ts +184 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/connect-account.write.ts +137 -84
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/disconnect-account.write.ts +65 -45
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-prepare.ts +177 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message-schema.ts +42 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/ingest-message.write.ts +79 -160
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +93 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-messages.query.ts +46 -30
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-quarantine.query.ts +106 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/mark-credential-stored.write.ts +47 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.query.ts +99 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-content.ts +229 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/message-detail.query.ts +34 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/reactivate-account.write.ts +129 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/retry-quarantined-message.write.ts +49 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/scope-visibility.ts +48 -9
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/set-trusted-authserv-id.write.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/thread-rollup.ts +136 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/trusted-authserv-id.ts +4 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/update-account.write.ts +82 -60
- package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +80 -2
- package/packages/bundled-features/src/inbound-mail-foundation/inbound-oauth-flow.ts +48 -0
- package/packages/bundled-features/src/inbound-mail-foundation/index.ts +93 -15
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-failures.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/ingest-payload.ts +46 -0
- package/packages/bundled-features/src/inbound-mail-foundation/mail-roles.ts +132 -0
- package/packages/bundled-features/src/inbound-mail-foundation/normalize-inbound-message.ts +119 -0
- package/packages/bundled-features/src/inbound-mail-foundation/oauth-access-token.ts +26 -8
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachment-access.ts +28 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-attachments.ts +111 -0
- package/packages/bundled-features/src/inbound-mail-foundation/outbound-mail.ts +19 -1
- package/packages/bundled-features/src/inbound-mail-foundation/probe.ts +2 -1
- package/packages/bundled-features/src/inbound-mail-foundation/projection.ts +33 -2
- package/packages/bundled-features/src/inbound-mail-foundation/provider-errors.ts +74 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-entities.ts +56 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-header-import.ts +63 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-retry.ts +137 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine-screens.ts +66 -0
- package/packages/bundled-features/src/inbound-mail-foundation/quarantine.ts +72 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-job.ts +78 -0
- package/packages/bundled-features/src/inbound-mail-foundation/retention-sweep.ts +43 -8
- package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +238 -89
- package/packages/bundled-features/src/inbound-mail-foundation/send-from-account.ts +24 -5
- package/packages/bundled-features/src/inbound-mail-foundation/sender-auth.ts +215 -0
- package/packages/bundled-features/src/inbound-mail-foundation/supervisor-ingest.ts +311 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-account-now.ts +61 -0
- package/packages/bundled-features/src/inbound-mail-foundation/sync-cursor-store.ts +79 -0
- package/packages/bundled-features/src/inbound-mail-foundation/syncable-accounts.ts +123 -0
- package/packages/bundled-features/src/inbound-mail-foundation/tenant-destroy-hook.ts +5 -1
- package/packages/bundled-features/src/inbound-mail-foundation/types.ts +76 -105
- package/packages/bundled-features/src/inbound-mail-foundation/wake-consumer.ts +33 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-catch-up.ts +68 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-lease.ts +124 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor-types.ts +87 -0
- package/packages/bundled-features/src/inbound-mail-foundation/watch-supervisor.ts +143 -433
- package/packages/bundled-features/src/inbound-mail-foundation/web/client-plugin.ts +73 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/constants.ts +7 -0
- package/packages/bundled-features/src/inbound-mail-foundation/web/index.ts +5 -0
- package/packages/bundled-features/src/inbound-provider-imap/changes.json +30 -0
- package/packages/bundled-features/src/inbound-provider-imap/connect-mailbox.ts +124 -41
- package/packages/bundled-features/src/inbound-provider-imap/connect-my-mailbox.ts +45 -0
- package/packages/bundled-features/src/inbound-provider-imap/constants.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/credential-document.ts +2 -1
- package/packages/bundled-features/src/inbound-provider-imap/download-batch.ts +38 -0
- package/packages/bundled-features/src/inbound-provider-imap/feature.ts +75 -49
- package/packages/bundled-features/src/inbound-provider-imap/fetch-content.ts +84 -0
- package/packages/bundled-features/src/inbound-provider-imap/i18n.ts +9 -0
- package/packages/bundled-features/src/inbound-provider-imap/imap-client.ts +35 -84
- package/packages/bundled-features/src/inbound-provider-imap/index.ts +1 -2
- package/packages/bundled-features/src/inbound-provider-imap/mime-content.ts +85 -0
- package/packages/bundled-features/src/inbound-provider-imap/parse-message.ts +149 -0
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-account.ts +4 -6
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-mailbox.ts +75 -45
- package/packages/bundled-features/src/inbound-provider-imap/reconnect-prefill.query.ts +19 -8
- package/packages/bundled-features/src/inbound-provider-imap/screens.ts +203 -109
- package/packages/bundled-features/src/inbound-provider-imap/send-mail.ts +11 -2
- package/packages/bundled-features/src/inbound-provider-imap/sender-auth-headers.ts +36 -0
- package/packages/bundled-features/src/inbound-provider-inmemory/feature.ts +75 -3
- package/packages/bundled-features/src/inbound-provider-inmemory/index.ts +6 -0
- package/packages/bundled-features/src/jobs/changes.json +18 -0
- package/packages/bundled-features/src/managed-pages/web/i18n.ts +1 -0
- package/packages/bundled-features/src/subscription-paddle/changes.json +15 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-config.query.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-methods.ts +107 -0
- package/packages/bundled-features/src/subscription-paddle/checkout-page.ts +154 -0
- package/packages/bundled-features/src/subscription-paddle/client.ts +78 -0
- package/packages/bundled-features/src/subscription-paddle/constants.ts +32 -0
- package/packages/bundled-features/src/subscription-paddle/feature.ts +265 -0
- package/packages/bundled-features/src/subscription-paddle/index.ts +17 -0
- package/packages/bundled-features/src/subscription-paddle/plan-switch-methods.ts +135 -0
- package/packages/bundled-features/src/subscription-paddle/price-methods.ts +148 -0
- package/packages/bundled-features/src/subscription-paddle/runtime.ts +203 -0
- package/packages/bundled-features/src/subscription-paddle/subscription-state.ts +73 -0
- package/packages/bundled-features/src/subscription-paddle/tenant-binding.ts +34 -0
- package/packages/bundled-features/src/subscription-paddle/verify-webhook.ts +61 -0
- package/packages/bundled-features/src/subscription-paddle/webhook-events.ts +172 -0
- package/packages/bundled-features/src/template-resolver/changes.json +12 -0
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +2 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +6 -2
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +25 -0
- package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +218 -117
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +1 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +2 -1
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-constants.ts +4 -0
- package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +11 -5
- package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +2 -2
- package/samples/apps/use-all-bundled/feature-manifest.json +87 -38
- package/samples/apps/use-all-bundled/kumiko/migrations/0056_inbound-mail-quarantine-retry.rebuild.json +4 -0
- package/samples/apps/use-all-bundled/package.json +1 -0
- package/samples/apps/use-all-bundled/playwright.billing-docs.config.ts +15 -0
- package/samples/apps/use-all-bundled/src/app/cap-overview-caps.ts +16 -2
- package/samples/apps/use-all-bundled/src/app/cap-overview-labels-feature.ts +2 -1
- package/samples/apps/use-all-bundled/src/app/client.tsx +2 -0
- package/samples/apps/use-all-bundled/src/app/screens-feature.ts +14 -2
- package/samples/apps/use-all-bundled/src/app/seed.ts +4 -1
- package/samples/apps/use-all-bundled/src/run-config.ts +7 -5
- package/samples/recipes/cross-feature-events/README.md +10 -0
- package/samples/recipes/cross-feature-events/src/feature.ts +14 -0
- package/samples/recipes/dashboard-stats/README.md +8 -0
- package/samples/recipes/dashboard-stats/src/feature.ts +91 -1
- package/samples/recipes/env-composition/README.md +2 -2
- package/samples/recipes/env-composition/src/env.ts +2 -2
- package/samples/recipes/inbound-mail-triage/README.md +8 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +71 -48
- package/packages/bundled-features/src/subscription-dodo/changes.json +0 -14
- package/packages/bundled-features/src/subscription-dodo/client.ts +0 -45
- package/packages/bundled-features/src/subscription-dodo/constants.ts +0 -24
- package/packages/bundled-features/src/subscription-dodo/feature.ts +0 -207
- package/packages/bundled-features/src/subscription-dodo/index.ts +0 -16
- package/packages/bundled-features/src/subscription-dodo/plugin-methods.ts +0 -345
- package/packages/bundled-features/src/subscription-dodo/runtime.ts +0 -126
- package/packages/bundled-features/src/subscription-dodo/verify-webhook.ts +0 -262
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Paddle plugin methods for checkout, portal, cancel and resume. Every response
|
|
2
|
+
// is validated with Zod; every non-2xx throws a PaddleHttpError.
|
|
3
|
+
|
|
4
|
+
import { SubscriptionCancelTimings } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
import type { PaddleCtxRuntime, PaddlePluginMethod } from "./runtime.ts";
|
|
7
|
+
import { signTenantBinding } from "./tenant-binding.ts";
|
|
8
|
+
|
|
9
|
+
export function paddleSubscriptionPath(providerSubscriptionId: string): string {
|
|
10
|
+
return `/subscriptions/${encodeURIComponent(providerSubscriptionId)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function relativeTarget(url: string): string {
|
|
14
|
+
const parsed = new URL(url);
|
|
15
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const checkoutResponseSchema = z.object({
|
|
19
|
+
data: z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
checkout: z.object({ url: z.string().nullable() }).nullish(),
|
|
22
|
+
}),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export function createPaddleCheckoutSession(
|
|
26
|
+
runtime: PaddleCtxRuntime,
|
|
27
|
+
): PaddlePluginMethod<"createCheckoutSession"> {
|
|
28
|
+
return async (ctx, options) => {
|
|
29
|
+
// No Paddle transaction may be created while billing is not live.
|
|
30
|
+
await runtime.assertBillingLive(ctx);
|
|
31
|
+
const bindingKey = await runtime.tenantBindingKey(ctx);
|
|
32
|
+
// No `checkout.url` in the body: Paddle rejects unapproved domains and otherwise
|
|
33
|
+
// answers with the dashboard's Default Payment Link plus `?_ptxn=`.
|
|
34
|
+
const { data } = checkoutResponseSchema.parse(
|
|
35
|
+
await runtime.call(ctx, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
path: "/transactions",
|
|
38
|
+
body: {
|
|
39
|
+
items: [{ price_id: options.priceId, quantity: 1 }],
|
|
40
|
+
...(options.providerCustomerId && { customer_id: options.providerCustomerId }),
|
|
41
|
+
custom_data: {
|
|
42
|
+
tenantId: options.tenantId,
|
|
43
|
+
tenantBinding: signTenantBinding(bindingKey, options.tenantId),
|
|
44
|
+
...(options.consentId && { consentId: options.consentId }),
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
if (!data.checkout?.url) {
|
|
50
|
+
throw new Error("subscription-paddle: transaction was created without a checkout url");
|
|
51
|
+
}
|
|
52
|
+
const url = new URL(data.checkout.url);
|
|
53
|
+
url.searchParams.set("success", relativeTarget(options.successUrl));
|
|
54
|
+
url.searchParams.set("cancel", relativeTarget(options.cancelUrl));
|
|
55
|
+
if (options.locale) url.searchParams.set("locale", options.locale);
|
|
56
|
+
return { url: url.href };
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const portalResponseSchema = z.object({
|
|
61
|
+
data: z.object({ urls: z.object({ general: z.object({ overview: z.string() }) }) }),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Paddle's portal has no return url, so options.returnUrl is not used.
|
|
65
|
+
export function createPaddlePortalSession(
|
|
66
|
+
runtime: PaddleCtxRuntime,
|
|
67
|
+
): PaddlePluginMethod<"createPortalSession"> {
|
|
68
|
+
return async (ctx, options) => {
|
|
69
|
+
const response = portalResponseSchema.parse(
|
|
70
|
+
await runtime.call(ctx, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
path: `/customers/${encodeURIComponent(options.providerCustomerId)}/portal-sessions`,
|
|
73
|
+
body: {},
|
|
74
|
+
}),
|
|
75
|
+
);
|
|
76
|
+
return { url: response.data.urls.general.overview };
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function createPaddleCancelSubscription(
|
|
81
|
+
runtime: PaddleCtxRuntime,
|
|
82
|
+
): PaddlePluginMethod<"cancelSubscription"> {
|
|
83
|
+
return async (ctx, options) => {
|
|
84
|
+
await runtime.call(ctx, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
path: `${paddleSubscriptionPath(options.providerSubscriptionId)}/cancel`,
|
|
87
|
+
body: {
|
|
88
|
+
effective_from:
|
|
89
|
+
options.when === SubscriptionCancelTimings.periodEnd
|
|
90
|
+
? "next_billing_period"
|
|
91
|
+
: "immediately",
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createPaddleResumeSubscription(
|
|
98
|
+
runtime: PaddleCtxRuntime,
|
|
99
|
+
): PaddlePluginMethod<"resumeSubscription"> {
|
|
100
|
+
return async (ctx, options) => {
|
|
101
|
+
await runtime.call(ctx, {
|
|
102
|
+
method: "PATCH",
|
|
103
|
+
path: paddleSubscriptionPath(options.providerSubscriptionId),
|
|
104
|
+
body: { scheduled_change: null },
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// The Default Payment Link page. Paddle has no hosted checkout, so Paddle.js
|
|
2
|
+
// runs on this page and opens the overlay for the `?_ptxn=` transaction by
|
|
3
|
+
// itself. The page never reflects `_ptxn`.
|
|
4
|
+
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { SYSTEM_TENANT_ID } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
|
|
8
|
+
import type { Context } from "hono";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import type { SystemQueryFn } from "../shared/index.ts";
|
|
11
|
+
import { PADDLE_CHECKOUT_CONFIG_QUERY, PaddleEnvironments } from "./constants.ts";
|
|
12
|
+
|
|
13
|
+
const PADDLE_JS_URL = "https://cdn.paddle.com/paddle/v2/paddle.js";
|
|
14
|
+
const MAX_REDIRECT_LENGTH = 2048;
|
|
15
|
+
const FALLBACK_REDIRECT = "/";
|
|
16
|
+
const LOCALE_PATTERN = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
17
|
+
// Control characters, which browsers strip or reinterpret inside URLs.
|
|
18
|
+
const CONTROL_CHARACTERS = /\p{Cc}/u;
|
|
19
|
+
|
|
20
|
+
// Static on purpose: its sha256 goes into the CSP, so nothing may be interpolated.
|
|
21
|
+
export const PADDLE_INIT_SCRIPT = `(function () {
|
|
22
|
+
var data = document.getElementById("kumiko-paddle-checkout").dataset;
|
|
23
|
+
var completed = false;
|
|
24
|
+
if (data.environment === "sandbox") Paddle.Environment.set("sandbox");
|
|
25
|
+
var settings = { displayMode: "overlay", successUrl: new URL(data.successUrl, location.origin).href };
|
|
26
|
+
if (data.locale) settings.locale = data.locale;
|
|
27
|
+
Paddle.Initialize({
|
|
28
|
+
token: data.clientToken,
|
|
29
|
+
checkout: { settings: settings },
|
|
30
|
+
eventCallback: function (event) {
|
|
31
|
+
if (event.name === "checkout.completed") completed = true;
|
|
32
|
+
if (event.name === "checkout.closed" && !completed) {
|
|
33
|
+
location.assign(new URL(data.cancelUrl, location.origin).href);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
})();`;
|
|
38
|
+
|
|
39
|
+
const INIT_SCRIPT_HASH = createHash("sha256").update(PADDLE_INIT_SCRIPT).digest("base64");
|
|
40
|
+
|
|
41
|
+
export const PADDLE_CHECKOUT_CSP = [
|
|
42
|
+
"default-src 'none'",
|
|
43
|
+
`script-src 'sha256-${INIT_SCRIPT_HASH}' https://cdn.paddle.com`,
|
|
44
|
+
"style-src 'unsafe-inline' https://cdn.paddle.com",
|
|
45
|
+
"img-src data: https://*.paddle.com",
|
|
46
|
+
"connect-src https://*.paddle.com",
|
|
47
|
+
"frame-src https://buy.paddle.com https://sandbox-buy.paddle.com",
|
|
48
|
+
"frame-ancestors 'none'",
|
|
49
|
+
"base-uri 'none'",
|
|
50
|
+
"form-action 'none'",
|
|
51
|
+
"object-src 'none'",
|
|
52
|
+
].join("; ");
|
|
53
|
+
|
|
54
|
+
// Own header set: `securePageHeaders` pins `script-src 'none'`. The global
|
|
55
|
+
// wrapper only fills headers that are absent, so these win.
|
|
56
|
+
function pageHeaders(): Record<string, string> {
|
|
57
|
+
return {
|
|
58
|
+
"content-type": "text/html; charset=utf-8",
|
|
59
|
+
"cache-control": "no-store",
|
|
60
|
+
"referrer-policy": "no-referrer",
|
|
61
|
+
"x-frame-options": "DENY",
|
|
62
|
+
"x-content-type-options": "nosniff",
|
|
63
|
+
"content-security-policy": PADDLE_CHECKOUT_CSP,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Same-origin path only: anything else would make the page an open redirector. */
|
|
68
|
+
export function safeRedirectPath(raw: string | undefined): string {
|
|
69
|
+
if (
|
|
70
|
+
raw === undefined ||
|
|
71
|
+
raw.length > MAX_REDIRECT_LENGTH ||
|
|
72
|
+
!raw.startsWith("/") ||
|
|
73
|
+
raw.startsWith("//") ||
|
|
74
|
+
raw.startsWith("/\\") ||
|
|
75
|
+
CONTROL_CHARACTERS.test(raw)
|
|
76
|
+
) {
|
|
77
|
+
return FALLBACK_REDIRECT;
|
|
78
|
+
}
|
|
79
|
+
return raw;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function safeLocale(raw: string | undefined): string | undefined {
|
|
83
|
+
return raw !== undefined && LOCALE_PATTERN.test(raw) ? raw : undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type CheckoutPageValues = {
|
|
87
|
+
readonly clientToken: string;
|
|
88
|
+
readonly environment: string;
|
|
89
|
+
readonly successUrl: string;
|
|
90
|
+
readonly cancelUrl: string;
|
|
91
|
+
readonly locale: string | undefined;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export function renderCheckoutPage(values: CheckoutPageValues): string {
|
|
95
|
+
const attributesHtml = [
|
|
96
|
+
["data-client-token", values.clientToken],
|
|
97
|
+
["data-environment", values.environment],
|
|
98
|
+
["data-success-url", values.successUrl],
|
|
99
|
+
["data-cancel-url", values.cancelUrl],
|
|
100
|
+
...(values.locale ? [["data-locale", values.locale]] : []),
|
|
101
|
+
]
|
|
102
|
+
.map(([name, value]) => `${name}="${escapeHtml(value ?? "")}"`)
|
|
103
|
+
.join(" ");
|
|
104
|
+
return `<!doctype html>
|
|
105
|
+
<html lang="en">
|
|
106
|
+
<head>
|
|
107
|
+
<meta charset="utf-8">
|
|
108
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
109
|
+
<title>Checkout</title>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
<div id="kumiko-paddle-checkout" ${attributesHtml}></div>
|
|
113
|
+
<noscript>JavaScript is required to complete the checkout.</noscript>
|
|
114
|
+
<script src="${PADDLE_JS_URL}"></script>
|
|
115
|
+
<script>${PADDLE_INIT_SCRIPT}</script>
|
|
116
|
+
</body>
|
|
117
|
+
</html>
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const checkoutConfigSchema = z.object({
|
|
122
|
+
clientToken: z.string().nullable(),
|
|
123
|
+
environment: z.string(),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
export async function servePaddleCheckoutPage(
|
|
127
|
+
c: Context,
|
|
128
|
+
{ systemQuery }: { readonly systemQuery: SystemQueryFn },
|
|
129
|
+
): Promise<Response> {
|
|
130
|
+
let config: z.infer<typeof checkoutConfigSchema>;
|
|
131
|
+
try {
|
|
132
|
+
config = checkoutConfigSchema.parse(
|
|
133
|
+
await systemQuery(PADDLE_CHECKOUT_CONFIG_QUERY, {}, SYSTEM_TENANT_ID),
|
|
134
|
+
);
|
|
135
|
+
} catch {
|
|
136
|
+
return unavailable(c);
|
|
137
|
+
}
|
|
138
|
+
if (!config.clientToken) return unavailable(c);
|
|
139
|
+
const html = renderCheckoutPage({
|
|
140
|
+
clientToken: config.clientToken,
|
|
141
|
+
environment:
|
|
142
|
+
config.environment === PaddleEnvironments.live
|
|
143
|
+
? PaddleEnvironments.live
|
|
144
|
+
: PaddleEnvironments.sandbox,
|
|
145
|
+
successUrl: safeRedirectPath(c.req.query("success")),
|
|
146
|
+
cancelUrl: safeRedirectPath(c.req.query("cancel")),
|
|
147
|
+
locale: safeLocale(c.req.query("locale")),
|
|
148
|
+
});
|
|
149
|
+
return new Response(html, { status: 200, headers: pageHeaders() });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function unavailable(c: Context): Response {
|
|
153
|
+
return c.text("checkout unavailable", 503, { "cache-control": "no-store" });
|
|
154
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Thin JSON-over-fetch client for the Paddle Billing 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
|
+
|
|
5
|
+
import { parseJsonOrThrow, parseJsonSafe } from "@cosmicdrift/kumiko-framework/utils";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
|
|
8
|
+
export type PaddleSend = (url: string, init?: RequestInit) => Promise<Response>;
|
|
9
|
+
|
|
10
|
+
export type PaddleRequest = {
|
|
11
|
+
readonly method: "GET" | "POST" | "PATCH";
|
|
12
|
+
/** Path segments interpolated into it must already be encodeURIComponent'ed. */
|
|
13
|
+
readonly path: string;
|
|
14
|
+
readonly query?: Readonly<Record<string, string>>;
|
|
15
|
+
readonly body?: unknown;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type PaddleTarget = { readonly baseUrl: string; readonly apiKey: string };
|
|
19
|
+
|
|
20
|
+
const PADDLE_API_VERSION = "1";
|
|
21
|
+
|
|
22
|
+
const paddleErrorBodySchema = z.object({ error: z.object({ type: z.string(), code: z.string() }) });
|
|
23
|
+
// Both values come from outside; anything beyond a plain identifier is dropped so
|
|
24
|
+
// no customer text reaches the logs.
|
|
25
|
+
const ERROR_IDENTIFIER = /^[a-z0-9_]{1,100}$/;
|
|
26
|
+
|
|
27
|
+
type PaddleErrorDetails = { readonly type?: string; readonly code?: string };
|
|
28
|
+
|
|
29
|
+
function paddleErrorDetails(bodyText: string): PaddleErrorDetails {
|
|
30
|
+
const parsed = paddleErrorBodySchema.safeParse(parseJsonSafe<unknown>(bodyText, undefined));
|
|
31
|
+
if (!parsed.success) return {};
|
|
32
|
+
const { type, code } = parsed.data.error;
|
|
33
|
+
return {
|
|
34
|
+
...(ERROR_IDENTIFIER.test(type) && { type }),
|
|
35
|
+
...(ERROR_IDENTIFIER.test(code) && { code }),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Carries only the status and Paddle's error type and code, never `detail`, the
|
|
40
|
+
// rest of the body or the key: they can hold customer data or secrets.
|
|
41
|
+
export class PaddleHttpError extends Error {
|
|
42
|
+
readonly status: number;
|
|
43
|
+
readonly paddleErrorType?: string;
|
|
44
|
+
readonly paddleErrorCode?: string;
|
|
45
|
+
constructor(request: PaddleRequest, status: number, details: PaddleErrorDetails = {}) {
|
|
46
|
+
const suffix = [details.type && `type ${details.type}`, details.code && `code ${details.code}`]
|
|
47
|
+
.filter(Boolean)
|
|
48
|
+
.join(", ");
|
|
49
|
+
super(
|
|
50
|
+
`subscription-paddle: ${request.method} ${request.path} failed with status ${status}${suffix ? ` (${suffix})` : ""}`,
|
|
51
|
+
);
|
|
52
|
+
this.name = "PaddleHttpError";
|
|
53
|
+
this.status = status;
|
|
54
|
+
if (details.type) this.paddleErrorType = details.type;
|
|
55
|
+
if (details.code) this.paddleErrorCode = details.code;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createPaddleHttp(send: PaddleSend) {
|
|
60
|
+
return async (target: PaddleTarget, request: PaddleRequest): Promise<unknown> => {
|
|
61
|
+
const query = request.query ? `?${new URLSearchParams(request.query).toString()}` : "";
|
|
62
|
+
const response = await send(`${target.baseUrl}${request.path}${query}`, {
|
|
63
|
+
method: request.method,
|
|
64
|
+
headers: {
|
|
65
|
+
Authorization: `Bearer ${target.apiKey}`,
|
|
66
|
+
Accept: "application/json",
|
|
67
|
+
"Content-Type": "application/json",
|
|
68
|
+
"Paddle-Version": PADDLE_API_VERSION,
|
|
69
|
+
},
|
|
70
|
+
...(request.body !== undefined && { body: JSON.stringify(request.body) }),
|
|
71
|
+
});
|
|
72
|
+
const text = await response.text();
|
|
73
|
+
if (!response.ok) throw new PaddleHttpError(request, response.status, paddleErrorDetails(text));
|
|
74
|
+
return text.length === 0
|
|
75
|
+
? null
|
|
76
|
+
: parseJsonOrThrow<unknown>(text, "subscription-paddle response");
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const SUBSCRIPTION_PADDLE_FEATURE = "subscription-paddle" as const;
|
|
2
|
+
|
|
3
|
+
// Must match the last path segment of the webhook URL (`/webhooks/subscription/paddle` via the boot option).
|
|
4
|
+
export const PADDLE_PROVIDER_NAME = "paddle" as const;
|
|
5
|
+
|
|
6
|
+
// Paddle has no hosted checkout page, so the feature serves the Default Payment
|
|
7
|
+
// Link itself; the same path goes into the Paddle dashboard.
|
|
8
|
+
export const PADDLE_CHECKOUT_PATH = "/billing/paddle-checkout" as const;
|
|
9
|
+
|
|
10
|
+
// Config-key short-names, qualified to `subscription-paddle:config:<name>` at
|
|
11
|
+
// registry-build. api-key, webhook-secret and tenant-binding-key are backing:"secrets".
|
|
12
|
+
export const PADDLE_API_KEY_CONFIG = "api-key" as const;
|
|
13
|
+
export const PADDLE_WEBHOOK_SECRET_CONFIG = "webhook-secret" as const;
|
|
14
|
+
export const PADDLE_TENANT_BINDING_KEY_CONFIG = "tenant-binding-key" as const;
|
|
15
|
+
export const PADDLE_CLIENT_TOKEN_CONFIG = "client-token" as const;
|
|
16
|
+
export const PADDLE_ENVIRONMENT_CONFIG = "environment" as const;
|
|
17
|
+
export const PADDLE_BILLING_LIVE_CONFIG = "billingLive" as const;
|
|
18
|
+
|
|
19
|
+
export const PADDLE_CHECKOUT_CONFIG_QUERY = `${SUBSCRIPTION_PADDLE_FEATURE}:query:checkout-config`;
|
|
20
|
+
|
|
21
|
+
// Separate from billingLive on purpose: staging runs Paddle's sandbox with
|
|
22
|
+
// billingLive on, production flips both.
|
|
23
|
+
export const PaddleEnvironments = {
|
|
24
|
+
sandbox: "sandbox",
|
|
25
|
+
live: "live",
|
|
26
|
+
} as const;
|
|
27
|
+
export type PaddleEnvironment = (typeof PaddleEnvironments)[keyof typeof PaddleEnvironments];
|
|
28
|
+
|
|
29
|
+
export const PADDLE_BASE_URLS: Readonly<Record<PaddleEnvironment, string>> = {
|
|
30
|
+
sandbox: "https://sandbox-api.paddle.com",
|
|
31
|
+
live: "https://api.paddle.com",
|
|
32
|
+
};
|
|
@@ -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";
|