@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.
@@ -13,25 +13,43 @@
13
13
  // Verdrahtungs-Bugs ab.
14
14
 
15
15
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import { randomBytes } from "node:crypto";
16
17
  import {
17
18
  billingFoundationFeature,
18
19
  createSubscriptionWebhookHandler,
19
20
  type SubscriptionProviderPlugin,
20
21
  subscriptionAggregateId,
21
22
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
22
- import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
23
- import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
23
+ import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
24
+ import { SYSTEM_TENANT_ID, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
24
25
  import { createEventsTable, loadAggregate } from "@cosmicdrift/kumiko-framework/event-store";
26
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
25
27
  import {
26
28
  createTestUser,
27
29
  setupTestStack,
28
30
  type TestStack,
29
31
  testTenantId,
32
+ unsafePushTables,
30
33
  } from "@cosmicdrift/kumiko-framework/stack";
31
34
  import { Hono } from "hono";
32
35
  import Stripe from "stripe";
36
+ import { configValuesTable, createConfigFeature } from "../../config";
37
+ import { createConfigAccessorFactory } from "../../config/feature";
38
+ import { createConfigResolver } from "../../config/resolver";
39
+ import {
40
+ createSecretsContext,
41
+ createSecretsFeature,
42
+ type SecretsContext,
43
+ tenantSecretsTable,
44
+ } from "../../secrets";
33
45
  import { createSubscriptionStripeFeature } from "../feature";
34
46
 
47
+ // Qualified-names der runtime-keys (drift-pin: müssen 1:1 dem entsprechen,
48
+ // was r.secret(...) im feature build qualifiziert — `subscription-stripe:
49
+ // secret:<shortName>`). Scenario 5 seedet darüber + beweist die Resolution.
50
+ const API_KEY_SECRET_QN = "subscription-stripe:secret:api-key";
51
+ const WEBHOOK_SECRET_QN = "subscription-stripe:secret:webhook-secret";
52
+
35
53
  // =============================================================================
36
54
  // Setup
37
55
  // =============================================================================
@@ -43,52 +61,93 @@ const PRICE_TO_TIER = { price_pro_monthly: "pro", price_business_yearly: "busine
43
61
  let stack: TestStack;
44
62
  let db: DbConnection;
45
63
  let webhookApp: Hono;
64
+ /** Zweite webhook-app MIT system-secrets gewired — für Scenario 5
65
+ * (runtime-secret-Pfad). */
66
+ let webhookAppWithSecrets: Hono;
67
+ let secretsCtx: SecretsContext;
46
68
 
47
69
  const stripeForFixtures = new Stripe(TEST_API_KEY, { apiVersion: "2026-04-22.dahlia" });
48
70
 
49
71
  beforeAll(async () => {
72
+ // subscription-stripe requires jetzt config + secrets. Scenarios 1–4
73
+ // nutzen den factory-fallback (kein system-secret geseedet, kein
74
+ // systemSecrets gewired) → resolve fällt auf die options-Keys zurück.
50
75
  const stripeFeature = createSubscriptionStripeFeature({
51
76
  webhookSecret: TEST_SECRET,
52
77
  apiKey: TEST_API_KEY,
53
78
  priceToTier: PRICE_TO_TIER,
54
79
  });
55
80
 
81
+ const encryption = createEncryptionProvider(randomBytes(32).toString("base64"));
82
+ const resolver = createConfigResolver({ encryption });
83
+ const masterKeyProvider = createEnvMasterKeyProvider({
84
+ env: {
85
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
86
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
87
+ },
88
+ });
89
+
56
90
  stack = await setupTestStack({
57
- features: [billingFoundationFeature, stripeFeature],
91
+ features: [
92
+ createConfigFeature(),
93
+ createSecretsFeature(),
94
+ billingFoundationFeature,
95
+ stripeFeature,
96
+ ],
97
+ masterKeyProvider,
98
+ extraContext: ({ db: ctxDb, registry }) => ({
99
+ configResolver: resolver,
100
+ configEncryption: encryption,
101
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolver),
102
+ secrets: createSecretsContext({ db: ctxDb, masterKeyProvider }),
103
+ }),
58
104
  });
59
105
  db = stack.db;
60
106
  // subscriptionsProjectionTable wird von setupTestStack automatisch
61
- // gepusht (r.projection mit `table`-Property → auto-push).
107
+ // gepusht (r.projection mit `table`-Property → auto-push). config +
108
+ // secrets brauchen ihre Tabellen explizit.
62
109
  await createEventsTable(db);
110
+ await unsafePushTables(db, { configValuesTable, tenant_secrets: tenantSecretsTable });
111
+ // Standalone-secrets-context (gleiche KEK) zum direkten Seeden +
112
+ // als systemSecrets für die zweite webhook-app.
113
+ secretsCtx = createSecretsContext({ db, masterKeyProvider });
63
114
 
64
115
  // Webhook-app: Hono mit der webhook-handler-Route.
65
116
  // dispatchWrite ruft `stack.http.write` mit dem System-User des
66
117
  // resolved-Tenants — das ist exakt was der App-Builder im echten
67
- // bin/server.ts via extraRoutes wireup macht.
68
- webhookApp = new Hono();
69
- webhookApp.post(
70
- "/api/subscription/webhook/:providerName",
71
- createSubscriptionWebhookHandler({
72
- dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
73
- const systemUser = createTestUser({
74
- id: 1,
75
- tenantId: tenantId as TenantId,
76
- roles: ["SystemAdmin"],
77
- });
78
- const res = await stack.http.write(handlerQn, payload, systemUser);
79
- const body = await res.json();
80
- return body.isSuccess
81
- ? { isSuccess: true, data: body.data }
82
- : { isSuccess: false, error: body.error };
83
- },
84
- resolveProvider: (providerName) => {
85
- const usage = stack.registry
86
- .getExtensionUsages("subscriptionProvider")
87
- .find((u) => u.entityName === providerName);
88
- return usage?.options as SubscriptionProviderPlugin | undefined;
89
- },
90
- }),
91
- );
118
+ // bin/server.ts via extraRoutes wireup macht. `systemSecrets` optional:
119
+ // ohne factory-fallback-Pfad (Scenarios 1–4); mit → runtime-secret-
120
+ // Pfad (Scenario 5), exakt wie der App-Owner createSecretsContext wired.
121
+ const mountWebhook = (systemSecrets?: SecretsContext): Hono => {
122
+ const app = new Hono();
123
+ app.post(
124
+ "/api/subscription/webhook/:providerName",
125
+ createSubscriptionWebhookHandler({
126
+ dispatchWrite: async ({ handlerQn, payload, tenantId }) => {
127
+ const systemUser = createTestUser({
128
+ id: 1,
129
+ tenantId: tenantId as TenantId,
130
+ roles: ["SystemAdmin"],
131
+ });
132
+ const res = await stack.http.write(handlerQn, payload, systemUser);
133
+ const body = await res.json();
134
+ return body.isSuccess
135
+ ? { isSuccess: true, data: body.data }
136
+ : { isSuccess: false, error: body.error };
137
+ },
138
+ resolveProvider: (providerName) => {
139
+ const usage = stack.registry
140
+ .getExtensionUsages("subscriptionProvider")
141
+ .find((u) => u.entityName === providerName);
142
+ return usage?.options as SubscriptionProviderPlugin | undefined;
143
+ },
144
+ ...(systemSecrets && { systemSecrets }),
145
+ }),
146
+ );
147
+ return app;
148
+ };
149
+ webhookApp = mountWebhook();
150
+ webhookAppWithSecrets = mountWebhook(secretsCtx);
92
151
  });
93
152
 
94
153
  afterAll(async () => {
@@ -149,8 +208,8 @@ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string>
149
208
  });
150
209
  }
151
210
 
152
- async function postStripeWebhook(payload: string, sig: string) {
153
- return webhookApp.request("/api/subscription/webhook/stripe", {
211
+ async function postStripeWebhook(payload: string, sig: string, app: Hono = webhookApp) {
212
+ return app.request("/api/subscription/webhook/stripe", {
154
213
  method: "POST",
155
214
  body: payload,
156
215
  headers: { "stripe-signature": sig, "content-type": "application/json" },
@@ -310,3 +369,69 @@ describe("scenario 4: ignored event-types pass through", () => {
310
369
  expect(subs.rows).toHaveLength(0);
311
370
  });
312
371
  });
372
+
373
+ // =============================================================================
374
+ // Scenario 5: runtime-secret-Pfad — webhook verifiziert gegen ein in der DB
375
+ // geseedetes system-secret (NICHT gegen den factory-fallback). Beweist die
376
+ // end-to-end-Resolution + dass das system-secret den Fallback schlägt
377
+ // (Rotation ohne Redeploy).
378
+ // =============================================================================
379
+
380
+ const SEEDED_WEBHOOK_SECRET = "whsec_runtime_seeded_distinct";
381
+ const SEEDED_API_KEY = "sk_test_runtime_seeded";
382
+
383
+ describe("scenario 5: runtime-secret resolution", () => {
384
+ test("seeded system-secret schlägt factory-fallback: sig gegen seeded secret → 200 + DB-row", async () => {
385
+ // Seed beide Keys als echte (encrypted) system-secrets unter
386
+ // SYSTEM_TENANT_ID — exakt was der Bridge-Seed / die Admin-UI in prod
387
+ // schreibt. SEEDED_WEBHOOK_SECRET ≠ TEST_SECRET (der fallback).
388
+ await secretsCtx.set(SYSTEM_TENANT_ID, API_KEY_SECRET_QN, SEEDED_API_KEY, {
389
+ updatedBy: "test",
390
+ });
391
+ await secretsCtx.set(SYSTEM_TENANT_ID, WEBHOOK_SECRET_QN, SEEDED_WEBHOOK_SECRET, {
392
+ updatedBy: "test",
393
+ });
394
+
395
+ const tenantStringId = testTenantId(4005);
396
+ const payload = JSON.stringify(
397
+ buildStripeSubscriptionEvent({
398
+ eventId: "evt_4005_runtime",
399
+ tenantId: tenantStringId,
400
+ subscriptionId: "sub_4005",
401
+ customerId: "cus_4005",
402
+ priceId: "price_pro_monthly",
403
+ }),
404
+ );
405
+ // Signiert mit dem GESEEDETEN secret — würde der webhook noch den
406
+ // fallback (TEST_SECRET) nutzen, schlüge die Verifikation fehl.
407
+ const sig = await signEvent(payload, SEEDED_WEBHOOK_SECRET);
408
+
409
+ const res = await postStripeWebhook(payload, sig, webhookAppWithSecrets);
410
+ expect(res.status).toBe(200);
411
+
412
+ const admin = createTestUser({
413
+ id: 4005,
414
+ tenantId: tenantStringId,
415
+ roles: ["TenantAdmin", "SystemAdmin"],
416
+ });
417
+ const subs = (await stack.http.queryOk(
418
+ "billing-foundation:query:subscription:list",
419
+ {},
420
+ admin,
421
+ )) as { rows: Array<Record<string, unknown>> };
422
+ expect(subs.rows).toHaveLength(1);
423
+ expect(subs.rows[0]?.["providerSubscriptionId"]).toBe("sub_4005");
424
+ expect(subs.rows[0]?.["tier"]).toBe("pro");
425
+ });
426
+
427
+ test("sig gegen den (jetzt obsoleten) fallback-secret → 401, wenn system-secret gesetzt ist", async () => {
428
+ // Drift-pin der Präzedenz: nachdem das system-secret gesetzt ist,
429
+ // darf der alte env/fallback-secret NICHT mehr verifizieren.
430
+ const payload = JSON.stringify(
431
+ buildStripeSubscriptionEvent({ eventId: "evt_4005_stale", tenantId: testTenantId(4006) }),
432
+ );
433
+ const sigWithStaleFallback = await signEvent(payload, TEST_SECRET);
434
+ const res = await postStripeWebhook(payload, sigWithStaleFallback, webhookAppWithSecrets);
435
+ expect(res.status).toBe(401);
436
+ });
437
+ });
@@ -13,6 +13,7 @@ import {
13
13
  SubscriptionStatuses,
14
14
  } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
15
15
  import Stripe from "stripe";
16
+ import type { StripeWebhookRuntime } from "../runtime";
16
17
  import {
17
18
  mapStripeEventType,
18
19
  mapStripeStatus,
@@ -28,6 +29,12 @@ const TEST_API_KEY = "sk_test_dummy_apikey";
28
29
 
29
30
  const stripeForFixtures = new Stripe(TEST_API_KEY, { apiVersion: "2026-04-22.dahlia" });
30
31
 
32
+ /** Test-runtime: liefert den fixture-Client + TEST_SECRET (statt sie aus
33
+ * system-secrets aufzulösen — die Resolution testet runtime.test.ts). */
34
+ function webhookRuntime(webhookSecret = TEST_SECRET): StripeWebhookRuntime {
35
+ return { resolve: async () => ({ stripe: stripeForFixtures, webhookSecret }) };
36
+ }
37
+
31
38
  function buildSubscriptionEvent(overrides: {
32
39
  eventType?: string;
33
40
  eventId?: string;
@@ -95,8 +102,7 @@ async function signEvent(payload: string, secret = TEST_SECRET): Promise<string>
95
102
  // =============================================================================
96
103
 
97
104
  describe("verifyAndParseStripeWebhook — sig-verify", () => {
98
- const verify = verifyAndParseStripeWebhook(stripeForFixtures, {
99
- webhookSecret: TEST_SECRET,
105
+ const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
100
106
  priceToTier: { price_pro_monthly: "pro" },
101
107
  });
102
108
 
@@ -142,8 +148,7 @@ describe("verifyAndParseStripeWebhook — sig-verify", () => {
142
148
  // =============================================================================
143
149
 
144
150
  describe("verifyAndParseStripeWebhook — event-filter", () => {
145
- const verify = verifyAndParseStripeWebhook(stripeForFixtures, {
146
- webhookSecret: TEST_SECRET,
151
+ const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
147
152
  priceToTier: { price_pro_monthly: "pro" },
148
153
  });
149
154
 
@@ -211,8 +216,7 @@ describe("verifyAndParseStripeWebhook — event-filter", () => {
211
216
  // =============================================================================
212
217
 
213
218
  describe("verifyAndParseStripeWebhook — tenant-resolution + price-to-tier", () => {
214
- const verify = verifyAndParseStripeWebhook(stripeForFixtures, {
215
- webhookSecret: TEST_SECRET,
219
+ const verify = verifyAndParseStripeWebhook(webhookRuntime(), {
216
220
  priceToTier: { price_pro_monthly: "pro", price_business_yearly: "business" },
217
221
  });
218
222
 
@@ -6,6 +6,17 @@ export const SUBSCRIPTION_STRIPE_FEATURE = "subscription-stripe" as const;
6
6
  // `/api/subscription/webhook/stripe`.
7
7
  export const STRIPE_PROVIDER_NAME = "stripe" as const;
8
8
 
9
+ // Stripe-API-version-pin. Zentral, damit jeder Client (egal ob mount-time-
10
+ // fallback oder runtime-rotiert) dieselbe API-Version spricht.
11
+ export const STRIPE_API_VERSION = "2026-04-22.dahlia" as const;
12
+
13
+ // Secret- + config-key short-names. Qualified zu `subscription-stripe:<name>`
14
+ // (secrets) bzw. `subscription-stripe:config:<name>` (config) beim
15
+ // registry-build.
16
+ export const STRIPE_API_KEY_SECRET = "api-key" as const;
17
+ export const STRIPE_WEBHOOK_SECRET_SECRET = "webhook-secret" as const;
18
+ export const STRIPE_BILLING_LIVE_CONFIG = "billingLive" as const;
19
+
9
20
  // =============================================================================
10
21
  // Stripe-event-types die wir auf normalisierte SubscriptionEventTypes
11
22
  // mappen. Stripe hat ~80 event-types insgesamt; wir filtern auf 5.
@@ -1,151 +1,175 @@
1
- // kumiko-feature-version: 1
1
+ // kumiko-feature-version: 2
2
2
  //
3
- // subscription-stripe — Stripe-Plugin für die subscription-foundation
3
+ // subscription-stripe — Stripe-Plugin für die billing-foundation
4
4
  // Plugin-API.
5
5
  //
6
- // **Factory-Pattern (= createSubscriptionStripeFeature(options)):**
7
- // Im Gegensatz zu mail-transport-smtp / file-provider-s3 (die ihre
8
- // secrets aus tenant-secrets lesen) ist Stripe's webhook-secret
9
- // **app-wide** App-Owner hat einen Stripe-account, alle Webhooks
10
- // gehen dorthin. Plugin braucht den secret beim webhook-sig-verify-
11
- // Zeitpunkt, der ist PRE-tenant-resolution (kein ctx).
6
+ // **Runtime-config (v2):** Stripe-credentials + der billing-live-Master-
7
+ // Switch kommen ZUR LAUFZEIT aus config/secrets, nicht mehr aus einem
8
+ // mount-time-Closure. Damit lassen sich Keys rotieren und prod live-
9
+ // schalten ohne Redeploy.
10
+ // - `subscription-stripe:api-key` + `:webhook-secret` **secrets**
11
+ // (encrypted-at-rest), gespeichert/gelesen unter SYSTEM_TENANT_ID
12
+ // (Stripe ist app-wide, secrets-v1 deklariert nur `scope:"tenant"`,
13
+ // also lebt der app-wide-Wert unter dem System-Tenant — dieselbe
14
+ // Konvention die der config-resolver für system-scope-rows nutzt).
15
+ // - `subscription-stripe:config:billingLive` → **system config**
16
+ // (boolean, default false). Der Master-Switch: ohne ihn darf kein
17
+ // checkout eine Stripe-Session erzeugen (#104-Invariante, write-side
18
+ // im createCheckoutSession-Gate durchgesetzt).
12
19
  //
13
- // Lösung: factory-Funktion `createSubscriptionStripeFeature(options)`
14
- // liest webhook-secret + apiKey beim mount-time aus dem Caller (= App-
15
- // Builder's bin/server.ts der's aus process.env zieht). Closure
16
- // hält's für den verifyAndParseWebhook-call.
20
+ // **Factory-options als Fallback:** `createSubscriptionStripeFeature({
21
+ // apiKey, webhookSecret, priceToTier })` bleibt apiKey/webhookSecret
22
+ // sind jetzt OPTIONAL und dienen nur als Fallback während der env→secrets-
23
+ // Bridge-Phase und in Tests, die keinen secrets-context wiren. `priceToTier`
24
+ // (Stripe-price-id → app-tier-name) bleibt eine factory-option: app-
25
+ // spezifisch, kein Secret, ändert sich selten.
17
26
  //
18
- // Beispiel-Verwendung in run-config.ts:
19
- //
20
- // import { createSubscriptionStripeFeature } from "@cosmicdrift/kumiko-bundled-features/subscription-stripe";
21
- //
22
- // const features = [
23
- // billingFoundationFeature,
24
- // createSubscriptionStripeFeature({
25
- // webhookSecret: process.env.STRIPE_WEBHOOK_SECRET ?? "",
26
- // apiKey: process.env.STRIPE_API_KEY ?? "",
27
- // priceToTier: {
28
- // "price_1ABC": "pro",
29
- // "price_1XYZ": "business",
30
- // },
31
- // }),
32
- // ];
33
- //
34
- // **Pattern-Vorbild:** mirrors createFeatureTogglesFeature(options) —
35
- // gleiche factory-Form für features die module-load-time-Konfiguration
36
- // haben (analog zum FeatureToggle-runtime-holder).
27
+ // **Webhook + system-secrets:** verifyAndParseWebhook ist pre-tenant
28
+ // (kein ctx). Der billing-foundation-webhook-handler reicht einen system-
29
+ // scoped SecretsContext als 3. Arg durch, aus dem der Plugin api-key +
30
+ // webhook-secret un-audited liest (sanctioned framework-internal read).
37
31
 
38
32
  import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
39
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
40
- import Stripe from "stripe";
33
+ import {
34
+ createSystemConfig,
35
+ defineFeature,
36
+ type FeatureDefinition,
37
+ } from "@cosmicdrift/kumiko-framework/engine";
41
38
  import { z } from "zod";
42
- import { STRIPE_PROVIDER_NAME, SUBSCRIPTION_STRIPE_FEATURE } from "./constants";
39
+ import {
40
+ STRIPE_API_KEY_SECRET,
41
+ STRIPE_BILLING_LIVE_CONFIG,
42
+ STRIPE_PROVIDER_NAME,
43
+ STRIPE_WEBHOOK_SECRET_SECRET,
44
+ SUBSCRIPTION_STRIPE_FEATURE,
45
+ } from "./constants";
43
46
  import {
44
47
  createStripeCancelSubscription,
45
48
  createStripeCheckoutSession,
46
49
  createStripePortalSession,
47
50
  } from "./plugin-methods";
51
+ import { createStripeRuntimes } from "./runtime";
48
52
  import { verifyAndParseStripeWebhook } from "./verify-webhook";
49
53
 
50
54
  /**
51
- * Env-vars contract for the `subscription-stripe` feature.
52
- *
53
- * The feature itself reads via factory-options (`createSubscriptionStripeFeature({
54
- * webhookSecret, apiKey })`), so the schema is a Kumiko-pattern contract:
55
- * apps that mount stripe SHOULD load `STRIPE_WEBHOOK_SECRET` / `STRIPE_API_KEY`
56
- * from env and forward them. `composeEnvSchema({ features: [stripeFeature] })`
57
- * surfaces missing/empty values at boot, before
58
- * `createSubscriptionStripeFeature` throws on `webhookSecret.length === 0`.
55
+ * Env-vars contract for the `subscription-stripe` feature — now a **bridge
56
+ * contract**: both fields are optional. v2 reads credentials from secrets
57
+ * at runtime; `STRIPE_WEBHOOK_SECRET` / `STRIPE_API_KEY` are only consumed
58
+ * as factory-fallback during the env→secrets transition. The regex still
59
+ * validates the shape when a value IS present, so a typo'd bridge key fails
60
+ * at boot rather than at the first webhook.
59
61
  */
60
62
  export const subscriptionStripeEnvSchema = z.object({
61
63
  STRIPE_WEBHOOK_SECRET: z
62
64
  .string()
63
65
  .regex(/^whsec_/, "STRIPE_WEBHOOK_SECRET must start with 'whsec_'")
64
- .describe("Stripe webhook-signing secret (`whsec_...` from the Stripe dashboard).")
65
- .meta({ kumiko: { pulumi: { secret: true } } }),
66
+ .describe("Stripe webhook-signing secret (`whsec_...`). Bridge-fallback — prefer the secret.")
67
+ .meta({ kumiko: { pulumi: { secret: true } } })
68
+ .optional(),
66
69
  STRIPE_API_KEY: z
67
70
  .string()
68
71
  .regex(
69
72
  /^(sk|rk)_(test|live)_/,
70
73
  "STRIPE_API_KEY must start with 'sk_test_'/'sk_live_' or a restricted 'rk_test_'/'rk_live_' key",
71
74
  )
72
- .describe("Stripe API key (`sk_live_...` / `sk_test_...`, restricted `rk_...` keys allowed).")
73
- .meta({ kumiko: { pulumi: { secret: true } } }),
75
+ .describe(
76
+ "Stripe API key (`sk_live_...` / `sk_test_...`). Bridge-fallback prefer the secret.",
77
+ )
78
+ .meta({ kumiko: { pulumi: { secret: true } } })
79
+ .optional(),
74
80
  });
75
81
 
76
82
  export type SubscriptionStripeOptions = {
77
- /** Webhook-secret aus dem Stripe-Dashboard. App-wide. Plugin throws
78
- * beim runtime wenn empty (= App-Owner hat sub-stripe gemountet
79
- * aber Stripe-Account nicht konfiguriert). */
80
- readonly webhookSecret: string;
81
- /** Stripe-API-key (sk_live_... / sk_test_...). Heute nur für
82
- * constructEvent-API-Version-Pin gebraucht; Phase 5.2b nutzt's
83
- * für outgoing-API-calls (createPortalSession etc.). */
84
- readonly apiKey: string;
85
- /** Price-to-tier-Mapping. Plugin liest die price-id aus Stripe-event
86
- * (subscription.items.data[0].price.id) und mappt auf einen tier-
87
- * name. Fehlt die price-id im Mapping → null (foundation 200
88
- * ignored App-Owner-Bug, hat den Stripe-price angelegt aber
89
- * nicht zur tier zugeordnet). */
90
- readonly priceToTier: Readonly<Record<string, string>>;
83
+ /** Bridge-fallback webhook-secret. Optional: v2 liest aus
84
+ * `subscription-stripe:webhook-secret` (system-secret). Gesetzt nur
85
+ * während der env→secrets-Übergangsphase / in Tests. */
86
+ readonly webhookSecret?: string;
87
+ /** Bridge-fallback api-key. Optional: v2 liest aus
88
+ * `subscription-stripe:api-key` (system-secret). */
89
+ readonly apiKey?: string;
90
+ /** Price-to-tier-Mapping. Plugin liest die price-id aus dem Stripe-event
91
+ * (subscription.items.data[0].price.id) und mappt auf einen tier-name.
92
+ * App-spezifisch → bleibt factory-option. Fehlt die price-id im Mapping
93
+ * → null (event ignored). */
94
+ readonly priceToTier?: Readonly<Record<string, string>>;
91
95
  };
92
96
 
97
+ const SECRET_REDACT = (plaintext: string): string =>
98
+ plaintext.length < 12
99
+ ? "•".repeat(plaintext.length)
100
+ : `${plaintext.slice(0, 8)}...${plaintext.slice(-4)}`;
101
+
93
102
  /**
94
- * Factory für das subscription-stripe-feature. Wird mit den App-Owner-
95
- * eigenen Stripe-Credentials gemountet. Der returnte FeatureDefinition
96
- * registriert den Plugin gegen subscription-foundation's
97
- * "subscriptionProvider"-extension-point unter entityName "stripe".
103
+ * Factory für das subscription-stripe-feature. Mountet IMMER (kein
104
+ * key-presence-Guard mehr) die Aktivität wird runtime über config/secrets
105
+ * gegatet (Muster wie feature-toggles). Der returnte FeatureDefinition
106
+ * registriert den Plugin gegen billing-foundation's "subscriptionProvider"-
107
+ * extension unter entityName "stripe".
98
108
  */
99
109
  export function createSubscriptionStripeFeature(
100
- options: SubscriptionStripeOptions,
110
+ options: SubscriptionStripeOptions = {},
101
111
  ): FeatureDefinition {
102
- // Module-load-Validation: ohne webhook-secret kann der Plugin keinen
103
- // single Webhook verifizieren. Throw vor dem mount damit der App-
104
- // Owner nicht zur Laufzeit Mystery-401s sieht.
105
- if (options.webhookSecret.length === 0) {
106
- throw new Error(
107
- "subscription-stripe: webhookSecret is empty. Set STRIPE_WEBHOOK_SECRET (or system-config) before mounting.",
108
- );
109
- }
110
- if (options.apiKey.length === 0) {
111
- throw new Error(
112
- "subscription-stripe: apiKey is empty. Set STRIPE_API_KEY (or system-config) before mounting.",
113
- );
114
- }
115
-
116
- // EIN Stripe-Client für alle vier plugin-methods (verify-webhook +
117
- // checkout + portal + cancel). API-version-pin zentral, kein
118
- // Connection-Duplikat.
119
- const stripe = new Stripe(options.apiKey, { apiVersion: "2026-04-22.dahlia" });
120
-
121
- const verifyAndParse = verifyAndParseStripeWebhook(stripe, {
122
- webhookSecret: options.webhookSecret,
123
- priceToTier: options.priceToTier,
124
- });
125
- const checkoutSession = createStripeCheckoutSession(stripe);
126
- const portalSession = createStripePortalSession(stripe);
127
- const cancel = createStripeCancelSubscription(stripe);
128
-
129
112
  return defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
130
113
  r.describe(
131
- "Stripe payment provider plugin for `billing-foundation`. Mount via `createSubscriptionStripeFeature({ webhookSecret, apiKey, priceToTier })` \u2014 a factory function that holds the app-wide credentials in a closure for use before tenant resolution. Implements all four provider methods: `verifyAndParseWebhook` (HMAC signature verification), `createCheckoutSession`, `createPortalSession`, and `cancelSubscription`. The `priceToTier` map connects Stripe price IDs to your tier names.",
114
+ "Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from the **secrets** feature (stored under the system tenant) and a `billingLive` **system config** flag all at runtime, so keys rotate and prod goes live without a redeploy. Mount via `createSubscriptionStripeFeature({ priceToTier })`; the optional `apiKey`/`webhookSecret` options are env→secrets bridge fallbacks. The plugin always mounts — `createCheckoutSession` throws `feature_disabled` unless `billingLive` is true, so sk_test_ keys in prod never produce a live checkout. Implements all four provider methods (webhook verify, checkout, portal, cancel).",
132
115
  );
133
- // Hard-deps: subscription-foundation als plugin-host. KEIN
134
- // `r.requires("config", "secrets")` — der Plugin nutzt weder
135
- // tenant-config noch tenant-secrets (alles app-wide via factory-
136
- // options).
116
+ // Hard-deps: billing-foundation (plugin-host) + config (billing-live)
117
+ // + secrets (api-key/webhook-secret).
137
118
  r.requires("billing-foundation");
119
+ r.requires("config");
120
+ r.requires("secrets");
138
121
  r.envSchema(subscriptionStripeEnvSchema);
139
122
 
140
- // Plugin: register against subscription-foundation's
141
- // "subscriptionProvider" extension. entityName "stripe" matcht den
142
- // path-segment in der webhook-URL (`/api/subscription/webhook/stripe`).
123
+ // Runtime-credentials. scope "tenant" (secrets-v1) — gespeichert/gelesen
124
+ // unter SYSTEM_TENANT_ID (app-wide). required:false: der factory-Fallback
125
+ // deckt die Bridge-Phase, also kein readiness-false-negative.
126
+ const apiKeySecret = r.secret(STRIPE_API_KEY_SECRET, {
127
+ label: { de: "Stripe API Key", en: "Stripe API Key" },
128
+ hint: {
129
+ de: "Geheimer Stripe-Schlüssel (`sk_live_...`). Im Stripe-Dashboard unter Entwickler → API-Schlüssel.",
130
+ en: "Stripe secret key (`sk_live_...`). Stripe dashboard → Developers → API keys.",
131
+ },
132
+ redact: SECRET_REDACT,
133
+ scope: "tenant",
134
+ required: false,
135
+ });
136
+ const webhookSecret = r.secret(STRIPE_WEBHOOK_SECRET_SECRET, {
137
+ label: { de: "Stripe Webhook Secret", en: "Stripe Webhook Secret" },
138
+ hint: {
139
+ de: "Webhook-Signing-Secret (`whsec_...`). Im Stripe-Dashboard beim Webhook-Endpoint.",
140
+ en: "Webhook signing secret (`whsec_...`). Stripe dashboard → the webhook endpoint.",
141
+ },
142
+ redact: SECRET_REDACT,
143
+ scope: "tenant",
144
+ required: false,
145
+ });
146
+
147
+ const configKeys = r.config({
148
+ keys: {
149
+ [STRIPE_BILLING_LIVE_CONFIG]: createSystemConfig("boolean", { default: false }),
150
+ },
151
+ });
152
+
153
+ const runtimes = createStripeRuntimes({
154
+ apiKeyHandle: apiKeySecret,
155
+ webhookSecretHandle: webhookSecret,
156
+ billingLiveHandle: configKeys[STRIPE_BILLING_LIVE_CONFIG],
157
+ fallback: {
158
+ ...(options.apiKey !== undefined && { apiKey: options.apiKey }),
159
+ ...(options.webhookSecret !== undefined && { webhookSecret: options.webhookSecret }),
160
+ },
161
+ });
162
+
143
163
  const plugin: SubscriptionProviderPlugin = {
144
- verifyAndParseWebhook: verifyAndParse,
145
- createCheckoutSession: checkoutSession,
146
- createPortalSession: portalSession,
147
- cancelSubscription: cancel,
164
+ verifyAndParseWebhook: verifyAndParseStripeWebhook(runtimes.webhook, {
165
+ priceToTier: options.priceToTier ?? {},
166
+ }),
167
+ createCheckoutSession: createStripeCheckoutSession(runtimes.ctx),
168
+ createPortalSession: createStripePortalSession(runtimes.ctx),
169
+ cancelSubscription: createStripeCancelSubscription(runtimes.ctx),
148
170
  };
149
171
  r.useExtension("subscriptionProvider", STRIPE_PROVIDER_NAME, plugin);
172
+
173
+ return { apiKeySecret, webhookSecret, configKeys };
150
174
  });
151
175
  }