@cosmicdrift/kumiko-bundled-features 0.41.0 → 0.42.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -245,8 +245,13 @@ describe("compose across all Phase-2 features", () => {
245
245
  const out = asBootError(err).format();
246
246
  expect(out).toContain("✗ JWT_SECRET (auth-email-password, required, missing)");
247
247
  expect(out).toContain("✗ KUMIKO_SECRETS_MASTER_KEY_V1 (secrets, required, missing)");
248
- expect(out).toContain("✗ STRIPE_API_KEY (subscription-stripe, required, missing)");
249
248
  expect(out).toContain("✗ MOLLIE_API_KEY (subscription-mollie, required, missing)");
249
+ // subscription-stripe-v2: STRIPE_API_KEY/STRIPE_WEBHOOK_SECRET sind
250
+ // jetzt optionale env→secrets-Bridge-Fallbacks, also KEIN missing-
251
+ // required-Boot-Fehler mehr — credentials kommen zur Laufzeit aus
252
+ // secrets.
253
+ expect(out).not.toContain("STRIPE_API_KEY");
254
+ expect(out).not.toContain("STRIPE_WEBHOOK_SECRET");
250
255
  }
251
256
  });
252
257
  });
@@ -6,10 +6,14 @@
6
6
  // **Two-phase plugin contract:**
7
7
  // 1. **Pre-tenant-resolution:** `verifyAndParseWebhook` läuft BEVOR
8
8
  // ein Tenant aus dem Event aufgelöst ist — kein HandlerContext
9
- // verfügbar. Plugin liest seinen webhook-secret aus
10
- // module-load-Closure (ENV-VAR oder system-config), NICHT aus
11
- // ctx. **Webhook-secret ist app-wide**, nicht per-tenant das
12
- // ist App-Owner's Stripe-/PayPal-Account, nicht Tenant-Sache.
9
+ // verfügbar. Das **Webhook-secret ist app-wide** (App-Owner's
10
+ // Stripe-/PayPal-Account, nicht Tenant-Sache). Damit ein Plugin den
11
+ // secret ZUR LAUFFZEIT aus dem secrets-Feature lesen kann (statt aus
12
+ // einem mount-time-Closure), reicht der webhook-handler einen
13
+ // optionalen system-scoped `SecretsContext` als 3. Arg durch — der
14
+ // Plugin liest seine app-wide-Secrets daraus un-audited unter
15
+ // SYSTEM_TENANT_ID. Plugins, die ihre Keys weiter aus einem Closure
16
+ // halten (z.B. mollie), ignorieren den Param → backward-compatible.
13
17
  // 2. **Post-tenant-resolution:** `createPortalSession` +
14
18
  // `cancelSubscription` werden aus regulären write-handlern
15
19
  // gerufen mit voll-aufgelöstem HandlerContext. Plugin kann
@@ -21,6 +25,7 @@
21
25
  // und sind über den Customer-Portal-Link erreichbar.
22
26
 
23
27
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
28
+ import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
24
29
  import type { SubscriptionEventType, SubscriptionStatus } from "./constants";
25
30
 
26
31
  // =============================================================================
@@ -75,9 +80,12 @@ export type SubscriptionProviderPlugin = {
75
80
  * (Plugin macht die Tenant-Resolution selbst aus dem provider-
76
81
  * payload metadata).
77
82
  *
78
- * Plugin liest seinen webhook-secret aus module-load-Closure
79
- * (process.env.STRIPE_WEBHOOK_SECRET oder system-config), NICHT
80
- * aus ctx. App-wide-secret = App-Owner's eigener Provider-Account.
83
+ * App-wide-secret = App-Owner's eigener Provider-Account. Der Plugin
84
+ * liest ihn entweder aus einem mount-time-Closure ODER — zur Laufzeit
85
+ * rotierbar aus dem optionalen `systemSecrets`-Arg (system-scoped
86
+ * SecretsContext, vom webhook-handler durchgereicht). `systemSecrets`
87
+ * ist undefined, wenn der App-Owner keinen wired; Plugins müssen dann
88
+ * auf ihren Closure-Fallback zurückfallen.
81
89
  *
82
90
  * Returns null für events die der Plugin nicht versteht oder die
83
91
  * foundation nicht braucht (= filter out, foundation returnt 200
@@ -90,6 +98,7 @@ export type SubscriptionProviderPlugin = {
90
98
  readonly verifyAndParseWebhook: (
91
99
  rawBody: string,
92
100
  headers: Record<string, string>,
101
+ systemSecrets?: SecretsContext,
93
102
  ) => Promise<SubscriptionEvent | null>;
94
103
 
95
104
  /**
@@ -72,6 +72,15 @@ export type SubscriptionWebhookDeps = {
72
72
  * Pfad und returnt den passenden Plugin (= entityName-match in
73
73
  * registry.getExtensionUsages("subscriptionProvider")). */
74
74
  readonly resolveProvider: (providerName: string) => SubscriptionProviderPlugin | undefined;
75
+
76
+ /** Optionaler system-scoped SecretsContext, durchgereicht an
77
+ * `verifyAndParseWebhook` (3. Arg). Erlaubt Plugins, ihre app-wide-
78
+ * Credentials (Stripe api-key/webhook-secret) zur Laufzeit aus
79
+ * secrets unter SYSTEM_TENANT_ID zu lesen statt aus einem mount-time-
80
+ * Closure. Der App-Owner baut ihn via `createSecretsContext({ db,
81
+ * masterKeyProvider })` aus den extraRoutes-deps. Fehlt er, fallen
82
+ * Plugins auf ihren Closure-Fallback zurück. */
83
+ readonly systemSecrets?: import("@cosmicdrift/kumiko-framework/secrets").SecretsContext;
75
84
  };
76
85
 
77
86
  /**
@@ -124,7 +133,7 @@ export function createSubscriptionWebhookHandler(deps: SubscriptionWebhookDeps)
124
133
  // retry won't help, Provider stopp").
125
134
  let parsed: Awaited<ReturnType<SubscriptionProviderPlugin["verifyAndParseWebhook"]>>;
126
135
  try {
127
- parsed = await plugin.verifyAndParseWebhook(rawBody, headers);
136
+ parsed = await plugin.verifyAndParseWebhook(rawBody, headers, deps.systemSecrets);
128
137
  } catch (e) {
129
138
  const msg = e instanceof Error ? e.message : String(e);
130
139
  return c.json(
@@ -4,52 +4,55 @@ import { describe, expect, test } from "bun:test";
4
4
  import { STRIPE_PROVIDER_NAME, StripeEventTypes, SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
5
5
  import { createSubscriptionStripeFeature } from "../feature";
6
6
 
7
- const VALID_OPTIONS = {
8
- webhookSecret: "whsec_test_dummy",
9
- apiKey: "sk_test_dummy",
7
+ const OPTIONS = {
10
8
  priceToTier: { price_test: "pro" },
11
9
  };
12
10
 
13
11
  describe("createSubscriptionStripeFeature — shape", () => {
14
12
  test("has the expected name", () => {
15
- const feature = createSubscriptionStripeFeature(VALID_OPTIONS);
13
+ const feature = createSubscriptionStripeFeature(OPTIONS);
16
14
  expect(feature.name).toBe(SUBSCRIPTION_STRIPE_FEATURE);
17
15
  expect(feature.name).toBe("subscription-stripe");
18
16
  });
19
17
 
20
- test("requires only subscription-foundation (NICHT config/secrets alles app-wide via factory-options)", () => {
21
- const feature = createSubscriptionStripeFeature(VALID_OPTIONS);
18
+ test("requires billing-foundation + config + secrets (runtime-keys via config/secrets)", () => {
19
+ const feature = createSubscriptionStripeFeature(OPTIONS);
22
20
  expect(feature.requires).toContain("billing-foundation");
23
- // Drift-Pin: webhook-secret + apiKey kommen aus factory-options
24
- // (= module-load-Closure), NICHT aus tenant-config/-secrets.
25
- expect(feature.requires).not.toContain("config");
26
- expect(feature.requires).not.toContain("secrets");
21
+ // Drift-Pin (v2): api-key/webhook-secret kommen ZUR LAUFZEIT aus
22
+ // secrets, billing-live aus config — daher harte deps auf beide.
23
+ expect(feature.requires).toContain("config");
24
+ expect(feature.requires).toContain("secrets");
27
25
  });
28
26
  });
29
27
 
30
- describe("createSubscriptionStripeFeature — module-load validation", () => {
31
- test("throws bei empty webhookSecret (= App-Owner hat sub-stripe gemountet aber Stripe-Account nicht konfiguriert)", () => {
28
+ describe("createSubscriptionStripeFeature — mounts without mount-time credentials", () => {
29
+ test("mounts with no options at all (keys resolved at runtime from secrets)", () => {
30
+ // v1 warf hier bei leerem webhookSecret/apiKey. v2 mountet immer —
31
+ // die Credentials kommen erst zur Laufzeit aus config/secrets, der
32
+ // billing-live-Gate hält den Checkout solange inert.
33
+ expect(() => createSubscriptionStripeFeature()).not.toThrow();
34
+ });
35
+
36
+ test("mounts with only priceToTier (no api/webhook keys passed)", () => {
32
37
  expect(() =>
33
- createSubscriptionStripeFeature({
34
- ...VALID_OPTIONS,
35
- webhookSecret: "",
36
- }),
37
- ).toThrow(/webhookSecret is empty/);
38
+ createSubscriptionStripeFeature({ priceToTier: { price_x: "pro" } }),
39
+ ).not.toThrow();
38
40
  });
39
41
 
40
- test("throws bei empty apiKey", () => {
42
+ test("mounts with bridge-fallback keys passed (env→secrets transition)", () => {
41
43
  expect(() =>
42
44
  createSubscriptionStripeFeature({
43
- ...VALID_OPTIONS,
44
- apiKey: "",
45
+ apiKey: "sk_test_dummy",
46
+ webhookSecret: "whsec_dummy",
47
+ priceToTier: { price_x: "pro" },
45
48
  }),
46
- ).toThrow(/apiKey is empty/);
49
+ ).not.toThrow();
47
50
  });
48
51
  });
49
52
 
50
53
  describe("subscription-stripe — plugin-registration", () => {
51
- test("registers itself under entityName 'stripe' for subscription-foundation's extension", () => {
52
- const feature = createSubscriptionStripeFeature(VALID_OPTIONS);
54
+ test("registers itself under entityName 'stripe' for billing-foundation's extension", () => {
55
+ const feature = createSubscriptionStripeFeature(OPTIONS);
53
56
  const usages = feature.extensionUsages;
54
57
  expect(
55
58
  usages.some(
@@ -65,7 +68,7 @@ describe("subscription-stripe — plugin-registration", () => {
65
68
  // "method not supported"-error brechen — type-check würde es nicht
66
69
  // fangen weil die useExtension-options als `unknown` durchgereicht
67
70
  // werden.
68
- const feature = createSubscriptionStripeFeature(VALID_OPTIONS);
71
+ const feature = createSubscriptionStripeFeature(OPTIONS);
69
72
  const usage = feature.extensionUsages.find((u) => u.entityName === STRIPE_PROVIDER_NAME);
70
73
  expect(usage).toBeDefined();
71
74
  const options = usage?.options as {
@@ -2,15 +2,24 @@
2
2
  // createPortalSession, cancelSubscription). Stripe-SDK-calls werden via
3
3
  // spyOn gemockt — wir testen unsere Mapping-Logik (Argumente die wir
4
4
  // an Stripe schicken + Antwort-Parsing), NICHT Stripe selbst.
5
+ //
6
+ // **Runtime-Wrapper:** die methods nehmen jetzt einen StripeCtxRuntime
7
+ // (löst Client + billing-live aus ctx auf), nicht mehr einen rohen
8
+ // Stripe-Client. `ctxRuntime(stripe, billingLive)` baut einen Test-runtime
9
+ // der den gespyten Client zurückgibt — die echte Resolution-Logik testet
10
+ // runtime.test.ts.
5
11
 
6
12
  import { describe, expect, spyOn, test } from "bun:test";
7
13
  import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
14
+ import { FeatureDisabledError } from "@cosmicdrift/kumiko-framework/errors";
8
15
  import Stripe from "stripe";
16
+ import { SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
9
17
  import {
10
18
  createStripeCancelSubscription,
11
19
  createStripeCheckoutSession,
12
20
  createStripePortalSession,
13
21
  } from "../plugin-methods";
22
+ import type { StripeCtxRuntime } from "../runtime";
14
23
 
15
24
  const TEST_API_KEY = "sk_test_dummy";
16
25
 
@@ -18,6 +27,19 @@ function buildStripe(): Stripe {
18
27
  return new Stripe(TEST_API_KEY, { apiVersion: "2026-04-22.dahlia" });
19
28
  }
20
29
 
30
+ /** Test-runtime: gibt den gespyten Client zurück + ein billing-live-Gate
31
+ * das (default) durchlässt. */
32
+ function ctxRuntime(stripe: Stripe, billingLive = true): StripeCtxRuntime {
33
+ return {
34
+ clientForCtx: async () => stripe,
35
+ assertBillingLive: async () => {
36
+ if (!billingLive) {
37
+ throw new FeatureDisabledError(SUBSCRIPTION_STRIPE_FEATURE, "create-checkout-session");
38
+ }
39
+ },
40
+ };
41
+ }
42
+
21
43
  const stubCtx = {} as HandlerContext;
22
44
 
23
45
  // =============================================================================
@@ -31,7 +53,7 @@ describe("createStripeCheckoutSession", () => {
31
53
  // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
32
54
  .mockResolvedValue({ url: "https://checkout.stripe.com/c/pay/test" } as any);
33
55
 
34
- const checkout = createStripeCheckoutSession(stripe);
56
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
35
57
  const result = await checkout(stubCtx, {
36
58
  priceId: "price_pro_monthly",
37
59
  tenantId: "tenant-001",
@@ -55,13 +77,32 @@ describe("createStripeCheckoutSession", () => {
55
77
  });
56
78
  });
57
79
 
80
+ test("#104-Gate: throws FeatureDisabledError + ruft Stripe NICHT wenn billing-live aus", async () => {
81
+ const stripe = buildStripe();
82
+ const createMock = spyOn(stripe.checkout.sessions, "create")
83
+ // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
84
+ .mockResolvedValue({ url: "https://x" } as any);
85
+
86
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe, false));
87
+ await expect(
88
+ checkout(stubCtx, {
89
+ priceId: "price_x",
90
+ tenantId: "t",
91
+ successUrl: "https://x/s",
92
+ cancelUrl: "https://x/c",
93
+ }),
94
+ ).rejects.toBeInstanceOf(FeatureDisabledError);
95
+ // Kein Stripe-Call — die Schranke greift VOR jeder Session-Erstellung.
96
+ expect(createMock).not.toHaveBeenCalled();
97
+ });
98
+
58
99
  test("passes existing customer-id wenn gesetzt (Plan-Wechsel-Flow)", async () => {
59
100
  const stripe = buildStripe();
60
101
  const createMock = spyOn(stripe.checkout.sessions, "create")
61
102
  // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
62
103
  .mockResolvedValue({ url: "https://x" } as any);
63
104
 
64
- const checkout = createStripeCheckoutSession(stripe);
105
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
65
106
  await checkout(stubCtx, {
66
107
  priceId: "price_x",
67
108
  tenantId: "tenant-002",
@@ -81,7 +122,7 @@ describe("createStripeCheckoutSession", () => {
81
122
  // biome-ignore lint/suspicious/noExplicitAny: SDK-Drift-Test
82
123
  .mockResolvedValue({ url: null } as any);
83
124
 
84
- const checkout = createStripeCheckoutSession(stripe);
125
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
85
126
  await expect(
86
127
  checkout(stubCtx, {
87
128
  priceId: "p",
@@ -102,7 +143,7 @@ describe("createStripeCheckoutSession", () => {
102
143
  new Error("Stripe API: Internal server error"),
103
144
  );
104
145
 
105
- const checkout = createStripeCheckoutSession(stripe);
146
+ const checkout = createStripeCheckoutSession(ctxRuntime(stripe));
106
147
  await expect(
107
148
  checkout(stubCtx, {
108
149
  priceId: "p",
@@ -125,7 +166,7 @@ describe("createStripePortalSession", () => {
125
166
  // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
126
167
  .mockResolvedValue({ url: "https://billing.stripe.com/p/session/test" } as any);
127
168
 
128
- const portal = createStripePortalSession(stripe);
169
+ const portal = createStripePortalSession(ctxRuntime(stripe));
129
170
  const result = await portal(stubCtx, {
130
171
  providerCustomerId: "cus_001",
131
172
  returnUrl: "https://example.com/return",
@@ -151,7 +192,7 @@ describe("createStripeCancelSubscription", () => {
151
192
  // biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
152
193
  .mockResolvedValue({ id: "sub_001", status: "canceled" } as any);
153
194
 
154
- const cancel = createStripeCancelSubscription(stripe);
195
+ const cancel = createStripeCancelSubscription(ctxRuntime(stripe));
155
196
  await cancel(stubCtx, "sub_001");
156
197
 
157
198
  expect(cancelMock).toHaveBeenCalledTimes(1);
@@ -0,0 +1,180 @@
1
+ // Unit-Tests für die Runtime-Key/Flag-Resolution (runtime.ts) — das Herz
2
+ // des v2-Pivots weg von mount-time-Closures. Stub-ctx/SecretsContext
3
+ // statt echter DB: wir testen die Resolution-Logik (secrets→fallback→throw,
4
+ // billing-live-Gate, Client-Memoization), nicht die secrets-Persistenz
5
+ // (das deckt der Integration-Test ab).
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import type {
9
+ ConfigKeyHandle,
10
+ HandlerContext,
11
+ SecretKeyHandle,
12
+ } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
14
+ import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
15
+ import Stripe from "stripe";
16
+ import { createStripeClientCache, createStripeRuntimes } from "../runtime";
17
+
18
+ const API_KEY_HANDLE: SecretKeyHandle = { name: "subscription-stripe:secret:api-key" };
19
+ const WEBHOOK_SECRET_HANDLE: SecretKeyHandle = {
20
+ name: "subscription-stripe:secret:webhook-secret",
21
+ };
22
+ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
23
+ name: "subscription-stripe:config:billingLive",
24
+ type: "boolean",
25
+ };
26
+
27
+ /** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
28
+ * qualified-name. Ignoriert auditCtx (irrelevant für die Resolution). */
29
+ function stubSecrets(values: Record<string, string>): SecretsContext {
30
+ const nameOf = (k: string | { readonly name: string }): string =>
31
+ typeof k === "string" ? k : k.name;
32
+ return {
33
+ get: async (_tenantId, key) => {
34
+ const value = values[nameOf(key)];
35
+ return value === undefined ? undefined : createSecret(value);
36
+ },
37
+ has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
38
+ set: async () => undefined,
39
+ delete: async () => false,
40
+ };
41
+ }
42
+
43
+ /** Minimaler HandlerContext-Stub mit nur den Feldern, die die ctx-
44
+ * Resolution liest (secrets, _userId für audit, config). */
45
+ function stubCtx(opts: { secrets?: SecretsContext; billingLive?: boolean }): HandlerContext {
46
+ return {
47
+ secrets: opts.secrets,
48
+ _userId: "tester",
49
+ config: async () => opts.billingLive,
50
+ } as unknown as HandlerContext; // @cast-boundary test-stub — partial ctx
51
+ }
52
+
53
+ function makeRuntimes(fallback: { apiKey?: string; webhookSecret?: string } = {}) {
54
+ return createStripeRuntimes({
55
+ apiKeyHandle: API_KEY_HANDLE,
56
+ webhookSecretHandle: WEBHOOK_SECRET_HANDLE,
57
+ billingLiveHandle: BILLING_LIVE_HANDLE,
58
+ fallback,
59
+ });
60
+ }
61
+
62
+ // =============================================================================
63
+ // createStripeClientCache
64
+ // =============================================================================
65
+
66
+ describe("createStripeClientCache", () => {
67
+ test("memoizes by api-key — same key → same instance, rotated key → new instance", () => {
68
+ const cache = createStripeClientCache();
69
+ const a1 = cache("sk_test_aaa");
70
+ const a2 = cache("sk_test_aaa");
71
+ const b1 = cache("sk_test_bbb");
72
+
73
+ expect(a1).toBeInstanceOf(Stripe);
74
+ expect(a2).toBe(a1); // steady-state reuses one client per key
75
+ expect(b1).not.toBe(a1); // rotation builds a fresh client
76
+ });
77
+ });
78
+
79
+ // =============================================================================
80
+ // ctx-runtime: api-key resolution (post-tenant, audited)
81
+ // =============================================================================
82
+
83
+ describe("StripeCtxRuntime.clientForCtx", () => {
84
+ test("reads api-key from system-secrets → builds a client", async () => {
85
+ const rt = makeRuntimes();
86
+ const ctx = stubCtx({ secrets: stubSecrets({ [API_KEY_HANDLE.name]: "sk_test_from_secret" }) });
87
+ const client = await rt.ctx.clientForCtx(ctx);
88
+ expect(client).toBeInstanceOf(Stripe);
89
+ });
90
+
91
+ test("secret takes precedence over factory-fallback; rotating the secret → new client", async () => {
92
+ const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
93
+ const values: Record<string, string> = { [API_KEY_HANDLE.name]: "sk_test_v1" };
94
+ const ctx = stubCtx({ secrets: stubSecrets(values) });
95
+
96
+ const first = await rt.ctx.clientForCtx(ctx);
97
+ const again = await rt.ctx.clientForCtx(ctx);
98
+ expect(again).toBe(first); // same secret → memoized
99
+
100
+ values[API_KEY_HANDLE.name] = "sk_test_v2"; // operator rotates the secret
101
+ const afterRotation = await rt.ctx.clientForCtx(ctx);
102
+ expect(afterRotation).not.toBe(first); // runtime re-read picked up the new key
103
+ });
104
+
105
+ test("falls back to factory api-key when no secret is set", async () => {
106
+ const rt = makeRuntimes({ apiKey: "sk_test_fallback" });
107
+ const ctx = stubCtx({ secrets: stubSecrets({}) });
108
+ const client = await rt.ctx.clientForCtx(ctx);
109
+ expect(client).toBeInstanceOf(Stripe);
110
+ });
111
+
112
+ test("throws UnconfiguredError when neither secret nor fallback is set", async () => {
113
+ const rt = makeRuntimes();
114
+ const ctx = stubCtx({ secrets: stubSecrets({}) });
115
+ await expect(rt.ctx.clientForCtx(ctx)).rejects.toBeInstanceOf(UnconfiguredError);
116
+ });
117
+ });
118
+
119
+ // =============================================================================
120
+ // ctx-runtime: billing-live gate (#104 invariant)
121
+ // =============================================================================
122
+
123
+ describe("StripeCtxRuntime.assertBillingLive", () => {
124
+ test("passes when billing-live config is true", async () => {
125
+ const rt = makeRuntimes();
126
+ await expect(rt.ctx.assertBillingLive(stubCtx({ billingLive: true }))).resolves.toBeUndefined();
127
+ });
128
+
129
+ test("throws FeatureDisabledError when billing-live is false", async () => {
130
+ const rt = makeRuntimes();
131
+ await expect(rt.ctx.assertBillingLive(stubCtx({ billingLive: false }))).rejects.toBeInstanceOf(
132
+ FeatureDisabledError,
133
+ );
134
+ });
135
+
136
+ test("throws FeatureDisabledError when billing-live is undefined (default-off)", async () => {
137
+ const rt = makeRuntimes();
138
+ await expect(
139
+ rt.ctx.assertBillingLive(stubCtx({ billingLive: undefined })),
140
+ ).rejects.toBeInstanceOf(FeatureDisabledError);
141
+ });
142
+ });
143
+
144
+ // =============================================================================
145
+ // webhook-runtime: pre-tenant resolution (raw, un-audited)
146
+ // =============================================================================
147
+
148
+ describe("StripeWebhookRuntime.resolve", () => {
149
+ test("resolves client + webhook-secret from system-secrets", async () => {
150
+ const rt = makeRuntimes();
151
+ const secrets = stubSecrets({
152
+ [API_KEY_HANDLE.name]: "sk_test_wh",
153
+ [WEBHOOK_SECRET_HANDLE.name]: "whsec_runtime",
154
+ });
155
+ const { stripe, webhookSecret } = await rt.webhook.resolve(secrets);
156
+ expect(stripe).toBeInstanceOf(Stripe);
157
+ expect(webhookSecret).toBe("whsec_runtime");
158
+ });
159
+
160
+ test("falls back to factory keys when no system-secrets passed", async () => {
161
+ const rt = makeRuntimes({ apiKey: "sk_test_fb", webhookSecret: "whsec_fb" });
162
+ const { webhookSecret } = await rt.webhook.resolve(undefined);
163
+ expect(webhookSecret).toBe("whsec_fb");
164
+ });
165
+
166
+ test("system-secret wins over fallback (rotation without redeploy)", async () => {
167
+ const rt = makeRuntimes({ webhookSecret: "whsec_stale_env" });
168
+ const secrets = stubSecrets({
169
+ [API_KEY_HANDLE.name]: "sk_test_x",
170
+ [WEBHOOK_SECRET_HANDLE.name]: "whsec_rotated",
171
+ });
172
+ const { webhookSecret } = await rt.webhook.resolve(secrets);
173
+ expect(webhookSecret).toBe("whsec_rotated");
174
+ });
175
+
176
+ test("throws when a key is unresolved (no secret, no fallback)", async () => {
177
+ const rt = makeRuntimes({ apiKey: "sk_test_only" }); // webhook-secret missing
178
+ await expect(rt.webhook.resolve(stubSecrets({}))).rejects.toThrow(/webhook-secret.*unresolved/);
179
+ });
180
+ });