@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.
@@ -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 (für ggf. tenant-spezifische Lookups).
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 Stripe from "stripe";
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(stripe: Stripe) {
34
- return async (_ctx: HandlerContext, options: StripeCheckoutOptions): Promise<{ url: string }> => {
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(stripe: Stripe) {
70
- return async (_ctx: HandlerContext, options: StripePortalOptions): Promise<{ url: string }> => {
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(stripe: Stripe) {
88
- return async (_ctx: HandlerContext, providerSubscriptionId: string): Promise<void> => {
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
- // **Invoice-event lazy-fetch (Phase 5.2b):**
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`. Closure über die `options` + den shared
52
- * Stripe-Client (kein ctx-arg das ist die Pre-tenant-resolution-Phase).
53
- *
54
- * **Shared Stripe-Client:** Der Caller (feature.ts) baut EINEN Stripe-
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
- stripe: Stripe,
65
+ runtime: StripeWebhookRuntime,
62
66
  options: StripeWebhookOptions,
63
- ): (rawBody: string, headers: Record<string, string>) => Promise<SubscriptionEvent | null> {
64
- return async (rawBody, headers) => {
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, options.webhookSecret);
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, Phase 5.2b).
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,