@cosmicdrift/kumiko-bundled-features 0.41.1 → 0.43.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/src/__tests__/env-schemas.test.ts +6 -1
- package/src/billing-foundation/types.ts +16 -7
- package/src/billing-foundation/webhook-handler.ts +10 -1
- package/src/subscription-stripe/__tests__/feature.test.ts +27 -24
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +47 -6
- package/src/subscription-stripe/__tests__/runtime.test.ts +180 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +156 -31
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +10 -6
- package/src/subscription-stripe/constants.ts +11 -0
- package/src/subscription-stripe/feature.ts +128 -104
- package/src/subscription-stripe/plugin-methods.ts +20 -8
- package/src/subscription-stripe/runtime.ts +156 -0
- package/src/subscription-stripe/verify-webhook.ts +33 -21
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
// Werden vom Plugin-build (feature.ts) als methods auf dem
|
|
5
5
|
// SubscriptionProviderPlugin registriert. Anders als
|
|
6
6
|
// verifyAndParseWebhook (= pre-tenant) bekommen diese den vollen
|
|
7
|
-
// HandlerContext
|
|
7
|
+
// HandlerContext — sie lösen den Stripe-Client zur CALL-Zeit aus dem
|
|
8
|
+
// runtime auf (api-key aus system-secrets, audited), statt aus einem
|
|
9
|
+
// mount-time-Closure. Key-Rotation wirkt damit ohne Redeploy.
|
|
8
10
|
//
|
|
9
11
|
// **Type-Ableitung:** die options-shapes der drei methods werden
|
|
10
12
|
// **direkt vom Plugin-Contract** abgeleitet (`Parameters<NonNullable
|
|
@@ -14,7 +16,7 @@
|
|
|
14
16
|
|
|
15
17
|
import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
16
18
|
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
-
import type
|
|
19
|
+
import type { StripeCtxRuntime } from "./runtime";
|
|
18
20
|
|
|
19
21
|
// =============================================================================
|
|
20
22
|
// createCheckoutSession
|
|
@@ -30,8 +32,16 @@ export type StripeCheckoutOptions = Parameters<
|
|
|
30
32
|
NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>
|
|
31
33
|
>[1];
|
|
32
34
|
|
|
33
|
-
export function createStripeCheckoutSession(
|
|
34
|
-
return async (
|
|
35
|
+
export function createStripeCheckoutSession(runtime: StripeCtxRuntime) {
|
|
36
|
+
return async (ctx: HandlerContext, options: StripeCheckoutOptions): Promise<{ url: string }> => {
|
|
37
|
+
// #104-Invariante: ohne billing-live darf keine Stripe-Session
|
|
38
|
+
// entstehen (sk_test_-Keys in prod erzeugen sonst einen Test-Mode-
|
|
39
|
+
// Checkout). Throw VOR jedem Stripe-Call. Früher hielt diese Schranke
|
|
40
|
+
// das ungemountete Plugin; jetzt mountet stripe immer, also gatet der
|
|
41
|
+
// billing-live-config-key write-side.
|
|
42
|
+
await runtime.assertBillingLive(ctx);
|
|
43
|
+
const stripe = await runtime.clientForCtx(ctx);
|
|
44
|
+
|
|
35
45
|
const session = await stripe.checkout.sessions.create({
|
|
36
46
|
mode: "subscription",
|
|
37
47
|
line_items: [{ price: options.priceId, quantity: 1 }],
|
|
@@ -66,8 +76,9 @@ export type StripePortalOptions = Parameters<
|
|
|
66
76
|
NonNullable<SubscriptionProviderPlugin["createPortalSession"]>
|
|
67
77
|
>[1];
|
|
68
78
|
|
|
69
|
-
export function createStripePortalSession(
|
|
70
|
-
return async (
|
|
79
|
+
export function createStripePortalSession(runtime: StripeCtxRuntime) {
|
|
80
|
+
return async (ctx: HandlerContext, options: StripePortalOptions): Promise<{ url: string }> => {
|
|
81
|
+
const stripe = await runtime.clientForCtx(ctx);
|
|
71
82
|
const session = await stripe.billingPortal.sessions.create({
|
|
72
83
|
customer: options.providerCustomerId,
|
|
73
84
|
return_url: options.returnUrl,
|
|
@@ -84,8 +95,9 @@ export function createStripePortalSession(stripe: Stripe) {
|
|
|
84
95
|
// state-update läuft über den normalen webhook-pfad. Diese function
|
|
85
96
|
// triggert nur die API-Cancellation.
|
|
86
97
|
|
|
87
|
-
export function createStripeCancelSubscription(
|
|
88
|
-
return async (
|
|
98
|
+
export function createStripeCancelSubscription(runtime: StripeCtxRuntime) {
|
|
99
|
+
return async (ctx: HandlerContext, providerSubscriptionId: string): Promise<void> => {
|
|
100
|
+
const stripe = await runtime.clientForCtx(ctx);
|
|
89
101
|
await stripe.subscriptions.cancel(providerSubscriptionId);
|
|
90
102
|
};
|
|
91
103
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Runtime key/flag-resolution for the subscription-stripe plugin.
|
|
2
|
+
//
|
|
3
|
+
// **Why this exists (the pivot away from mount-time closures):**
|
|
4
|
+
// v1 of this feature baked the Stripe credentials into a closure at
|
|
5
|
+
// `createSubscriptionStripeFeature({ apiKey, webhookSecret })`-mount-time.
|
|
6
|
+
// Rotating a key or flipping prod live then needed a redeploy. This module
|
|
7
|
+
// resolves both at CALL-time instead:
|
|
8
|
+
// - `api-key` + `webhook-secret` from the **secrets** feature, stored
|
|
9
|
+
// under SYSTEM_TENANT_ID (Stripe is app-wide, not per-tenant — secrets
|
|
10
|
+
// v1 only declares `scope:"tenant"`, so app-wide values live under the
|
|
11
|
+
// system tenant, the same convention the config-resolver uses for
|
|
12
|
+
// system-scope rows).
|
|
13
|
+
// - `billing-live` from a **system config** key (default false) — the
|
|
14
|
+
// master switch that gates whether a checkout may create a live session.
|
|
15
|
+
//
|
|
16
|
+
// The factory-options (`apiKey` / `webhookSecret`) survive as **optional
|
|
17
|
+
// fallbacks**: during the env→secrets bridge phase, and in tests that don't
|
|
18
|
+
// wire a secrets-context, the closure value is used when no secret is set.
|
|
19
|
+
//
|
|
20
|
+
// **Two read-paths, by tenant-resolution phase:**
|
|
21
|
+
// - Post-tenant (checkout/portal/cancel): full HandlerContext with a
|
|
22
|
+
// caller identity → audited read via `requireSecretsContext`.
|
|
23
|
+
// - Pre-tenant (webhook sig-verify): no ctx, no caller → raw
|
|
24
|
+
// `SecretsContext.get(SYSTEM_TENANT_ID, handle)` (the sanctioned
|
|
25
|
+
// un-audited framework-internal path). The foundation builds and passes
|
|
26
|
+
// this SecretsContext as the 3rd webhook arg.
|
|
27
|
+
|
|
28
|
+
import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
29
|
+
import {
|
|
30
|
+
type ConfigKeyHandle,
|
|
31
|
+
type HandlerContext,
|
|
32
|
+
type SecretKeyHandle,
|
|
33
|
+
SYSTEM_TENANT_ID,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
35
|
+
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
36
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
37
|
+
import Stripe from "stripe";
|
|
38
|
+
import { STRIPE_API_VERSION, SUBSCRIPTION_STRIPE_FEATURE } from "./constants";
|
|
39
|
+
|
|
40
|
+
const API_KEY_HINT =
|
|
41
|
+
"Set the system-scoped Stripe API key via secrets:write:set (or seed it from STRIPE_API_KEY during the env bridge).";
|
|
42
|
+
|
|
43
|
+
/** Memoize Stripe clients by api-key string — a fresh client is built only
|
|
44
|
+
* when the key actually changes (rotation), so steady-state calls reuse one
|
|
45
|
+
* connection-pooled instance per key. */
|
|
46
|
+
export function createStripeClientCache(): (apiKey: string) => Stripe {
|
|
47
|
+
const cache = new Map<string, Stripe>();
|
|
48
|
+
return (apiKey) => {
|
|
49
|
+
const cached = cache.get(apiKey);
|
|
50
|
+
if (cached) return cached;
|
|
51
|
+
const client = new Stripe(apiKey, { apiVersion: STRIPE_API_VERSION });
|
|
52
|
+
cache.set(apiKey, client);
|
|
53
|
+
return client;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type StripeRuntimeDeps = {
|
|
58
|
+
readonly apiKeyHandle: SecretKeyHandle;
|
|
59
|
+
readonly webhookSecretHandle: SecretKeyHandle;
|
|
60
|
+
readonly billingLiveHandle: ConfigKeyHandle<"boolean">;
|
|
61
|
+
readonly fallback: { readonly apiKey?: string; readonly webhookSecret?: string };
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Post-tenant runtime: used by checkout/portal/cancel which carry a full
|
|
65
|
+
* HandlerContext (audited secret reads, config-gate). */
|
|
66
|
+
export type StripeCtxRuntime = {
|
|
67
|
+
readonly clientForCtx: (ctx: HandlerContext) => Promise<Stripe>;
|
|
68
|
+
/** Throws FeatureDisabledError unless `billing-live` config is true. The
|
|
69
|
+
* #104 invariant: no Stripe session may be created while billing is not
|
|
70
|
+
* live (sk_test_ keys in prod must not produce a live checkout). */
|
|
71
|
+
readonly assertBillingLive: (ctx: HandlerContext) => Promise<void>;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Pre-tenant runtime: used by verifyAndParseWebhook (no ctx). Resolves both
|
|
75
|
+
* keys from the foundation-supplied system SecretsContext, with fallback. */
|
|
76
|
+
export type StripeWebhookRuntime = {
|
|
77
|
+
readonly resolve: (
|
|
78
|
+
systemSecrets?: SecretsContext,
|
|
79
|
+
) => Promise<{ readonly stripe: Stripe; readonly webhookSecret: string }>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type StripeRuntimes = {
|
|
83
|
+
readonly ctx: StripeCtxRuntime;
|
|
84
|
+
readonly webhook: StripeWebhookRuntime;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
88
|
+
const clientFor = createStripeClientCache();
|
|
89
|
+
|
|
90
|
+
async function ctxApiKey(ctx: HandlerContext): Promise<string> {
|
|
91
|
+
let key: string | undefined;
|
|
92
|
+
if (ctx.secrets) {
|
|
93
|
+
const got = await requireSecretsContext(ctx, SUBSCRIPTION_STRIPE_FEATURE).get(
|
|
94
|
+
SYSTEM_TENANT_ID,
|
|
95
|
+
deps.apiKeyHandle,
|
|
96
|
+
);
|
|
97
|
+
key = got?.reveal();
|
|
98
|
+
}
|
|
99
|
+
if (!key && deps.fallback.apiKey && deps.fallback.apiKey.length > 0) {
|
|
100
|
+
key = deps.fallback.apiKey;
|
|
101
|
+
}
|
|
102
|
+
if (!key) {
|
|
103
|
+
throw new UnconfiguredError({
|
|
104
|
+
feature: SUBSCRIPTION_STRIPE_FEATURE,
|
|
105
|
+
key: deps.apiKeyHandle.name,
|
|
106
|
+
hint: API_KEY_HINT,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return key;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function rawRead(
|
|
113
|
+
systemSecrets: SecretsContext | undefined,
|
|
114
|
+
handle: SecretKeyHandle,
|
|
115
|
+
fallback: string | undefined,
|
|
116
|
+
): Promise<string | undefined> {
|
|
117
|
+
if (systemSecrets) {
|
|
118
|
+
// No auditCtx → un-audited framework-internal read (every webhook would
|
|
119
|
+
// otherwise spam the audit trail). This is the sanctioned no-ctx path.
|
|
120
|
+
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle);
|
|
121
|
+
const value = got?.reveal();
|
|
122
|
+
if (value && value.length > 0) return value;
|
|
123
|
+
}
|
|
124
|
+
return fallback && fallback.length > 0 ? fallback : undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ctx: {
|
|
129
|
+
clientForCtx: async (ctx) => clientFor(await ctxApiKey(ctx)),
|
|
130
|
+
assertBillingLive: async (ctx) => {
|
|
131
|
+
const live = ctx.config ? await ctx.config(deps.billingLiveHandle) : undefined;
|
|
132
|
+
if (live !== true) {
|
|
133
|
+
throw new FeatureDisabledError(SUBSCRIPTION_STRIPE_FEATURE, "create-checkout-session");
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
webhook: {
|
|
138
|
+
resolve: async (systemSecrets) => {
|
|
139
|
+
const apiKey = await rawRead(systemSecrets, deps.apiKeyHandle, deps.fallback.apiKey);
|
|
140
|
+
const webhookSecret = await rawRead(
|
|
141
|
+
systemSecrets,
|
|
142
|
+
deps.webhookSecretHandle,
|
|
143
|
+
deps.fallback.webhookSecret,
|
|
144
|
+
);
|
|
145
|
+
if (!apiKey || !webhookSecret) {
|
|
146
|
+
const missing = !apiKey ? deps.apiKeyHandle.name : deps.webhookSecretHandle.name;
|
|
147
|
+
throw new Error(
|
|
148
|
+
`subscription-stripe: '${missing}' unresolved — no system-secret set and no factory fallback. ` +
|
|
149
|
+
"Webhook cannot verify until it is configured.",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return { stripe: clientFor(apiKey), webhookSecret };
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
}
|
|
@@ -12,7 +12,16 @@
|
|
|
12
12
|
// 3. Stripe-payload → SubscriptionEvent normalisieren
|
|
13
13
|
// (status-mapping, tenant-id aus metadata, price-to-tier-Lookup).
|
|
14
14
|
//
|
|
15
|
-
// **
|
|
15
|
+
// **Runtime-keys (pre-tenant):** api-key + webhook-secret kommen NICHT
|
|
16
|
+
// mehr aus einem mount-time-Closure, sondern werden zur Webhook-Zeit aus
|
|
17
|
+
// dem `StripeWebhookRuntime` aufgelöst. Der foundation-webhook-handler
|
|
18
|
+
// reicht einen system-scoped SecretsContext als 3. Arg durch; der runtime
|
|
19
|
+
// liest beide Keys daraus (un-audited, system-internal) mit Fallback auf
|
|
20
|
+
// die factory-options. Damit rotiert ein Key ohne Redeploy — und der
|
|
21
|
+
// invoice-lazy-fetch (unten) nutzt denselben rotierten Client wie der
|
|
22
|
+
// sig-verify, kein split-brain.
|
|
23
|
+
//
|
|
24
|
+
// **Invoice-event lazy-fetch:**
|
|
16
25
|
// Bei `invoice.paid` und `invoice.payment_failed` enthält der webhook-
|
|
17
26
|
// payload nur die subscription-id (Stripe-Webhooks expanden subscription
|
|
18
27
|
// nicht automatisch). Plugin macht einen lazy-fetch via
|
|
@@ -29,50 +38,54 @@ import {
|
|
|
29
38
|
type SubscriptionStatus,
|
|
30
39
|
SubscriptionStatuses,
|
|
31
40
|
} from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
41
|
+
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
32
42
|
import type Stripe from "stripe";
|
|
33
43
|
import { STRIPE_PROVIDER_NAME, StripeEventTypes } from "./constants";
|
|
44
|
+
import type { StripeWebhookRuntime } from "./runtime";
|
|
34
45
|
|
|
35
46
|
// =============================================================================
|
|
36
47
|
// Sig-verify + parse
|
|
37
48
|
// =============================================================================
|
|
38
49
|
|
|
39
50
|
export type StripeWebhookOptions = {
|
|
40
|
-
/** Webhook-secret aus dem Stripe-Dashboard. **App-wide**, nicht
|
|
41
|
-
* per-tenant. Liest aus ENV-VAR oder system-config beim Plugin-
|
|
42
|
-
* build. */
|
|
43
|
-
readonly webhookSecret: string;
|
|
44
51
|
/** Price-to-tier-Map. Plugin liest die price-id aus dem event und
|
|
45
|
-
* mapped auf tier-name. Fehlt die price-id im Mapping → null.
|
|
52
|
+
* mapped auf tier-name. Fehlt die price-id im Mapping → null. App-
|
|
53
|
+
* spezifisch, bleibt eine factory-option (kein Secret). */
|
|
46
54
|
readonly priceToTier: Readonly<Record<string, string>>;
|
|
47
55
|
};
|
|
48
56
|
|
|
49
57
|
/**
|
|
50
58
|
* Stripe-webhook-handler. Implementiert den Plugin-Contract
|
|
51
|
-
* `verifyAndParseWebhook`.
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
* Client beim mount und gibt ihn an alle vier plugin-methods weiter.
|
|
56
|
-
* Konstruktor-API-version-pin ist damit zentral; verify-webhook nutzt
|
|
57
|
-
* den client für `webhooks.constructEvent` (sig-verify) + lazy-fetch
|
|
58
|
-
* der invoice-events.
|
|
59
|
+
* `verifyAndParseWebhook`. **Pre-tenant-resolution** — kein
|
|
60
|
+
* HandlerContext; statt eines mount-time-Clients löst der `runtime` den
|
|
61
|
+
* Stripe-Client + das webhook-secret zur Call-Zeit aus dem optionalen
|
|
62
|
+
* system-SecretsContext (3. Arg) auf.
|
|
59
63
|
*/
|
|
60
64
|
export function verifyAndParseStripeWebhook(
|
|
61
|
-
|
|
65
|
+
runtime: StripeWebhookRuntime,
|
|
62
66
|
options: StripeWebhookOptions,
|
|
63
|
-
): (
|
|
64
|
-
|
|
67
|
+
): (
|
|
68
|
+
rawBody: string,
|
|
69
|
+
headers: Record<string, string>,
|
|
70
|
+
systemSecrets?: SecretsContext,
|
|
71
|
+
) => Promise<SubscriptionEvent | null> {
|
|
72
|
+
return async (rawBody, headers, systemSecrets) => {
|
|
65
73
|
const sigHeader = headers["stripe-signature"];
|
|
66
74
|
if (!sigHeader) {
|
|
67
75
|
throw new Error("subscription-stripe: stripe-signature header missing");
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
// 0. Runtime-resolve: api-key (für client + lazy-fetch) + webhook-
|
|
79
|
+
// secret (für sig-verify) aus system-secrets, Fallback factory-
|
|
80
|
+
// options. Wirft wenn beide unkonfiguriert.
|
|
81
|
+
const { stripe, webhookSecret } = await runtime.resolve(systemSecrets);
|
|
82
|
+
|
|
70
83
|
// 1. Sig-verify. constructEvent throws bei mismatch (= invalid sig)
|
|
71
84
|
// oder timestamp-tolerance-violation (default 5min). Foundation
|
|
72
85
|
// mapped throw → HTTP 401.
|
|
73
86
|
let event: Stripe.Event;
|
|
74
87
|
try {
|
|
75
|
-
event = await stripe.webhooks.constructEventAsync(rawBody, sigHeader,
|
|
88
|
+
event = await stripe.webhooks.constructEventAsync(rawBody, sigHeader, webhookSecret);
|
|
76
89
|
} catch (e) {
|
|
77
90
|
const msg = e instanceof Error ? e.message : String(e);
|
|
78
91
|
throw new Error(`subscription-stripe: webhook signature verify failed — ${msg}`);
|
|
@@ -87,7 +100,7 @@ export function verifyAndParseStripeWebhook(
|
|
|
87
100
|
// 3. Payload-extraction. Stripe liefert je nach event.type
|
|
88
101
|
// verschiedene data.object-shapes. Wir extrahieren die
|
|
89
102
|
// Subscription-Daten — entweder direkt (subscription-events)
|
|
90
|
-
// oder via lazy-fetch (invoice-events
|
|
103
|
+
// oder via lazy-fetch (invoice-events).
|
|
91
104
|
const sub = await extractSubscriptionFromEvent(event, stripe);
|
|
92
105
|
if (!sub) {
|
|
93
106
|
// event-type war unter den 5 (oben gefiltert), aber payload-shape
|
|
@@ -193,8 +206,7 @@ export function mapStripeStatus(stripeStatus: Stripe.Subscription.Status): Subsc
|
|
|
193
206
|
|
|
194
207
|
/** Holt die Subscription aus dem Event. Subscription-events haben sie
|
|
195
208
|
* direkt im data.object; invoice-events haben nur die subscription-id
|
|
196
|
-
* und brauchen einen lazy-fetch via stripe.subscriptions.retrieve
|
|
197
|
-
* (Phase 5.2b). */
|
|
209
|
+
* und brauchen einen lazy-fetch via stripe.subscriptions.retrieve. */
|
|
198
210
|
async function extractSubscriptionFromEvent(
|
|
199
211
|
event: Stripe.Event,
|
|
200
212
|
stripe: Stripe,
|