@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 +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
|
@@ -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
|
|
23
|
-
import type
|
|
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: [
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
// kumiko-feature-version: 2
|
|
2
2
|
//
|
|
3
|
-
// subscription-stripe — Stripe-Plugin für die
|
|
3
|
+
// subscription-stripe — Stripe-Plugin für die billing-foundation
|
|
4
4
|
// Plugin-API.
|
|
5
5
|
//
|
|
6
|
-
// **
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
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 {
|
|
40
|
-
|
|
33
|
+
import {
|
|
34
|
+
createSystemConfig,
|
|
35
|
+
defineFeature,
|
|
36
|
+
type FeatureDefinition,
|
|
37
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
41
38
|
import { z } from "zod";
|
|
42
|
-
import {
|
|
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
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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_...`
|
|
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(
|
|
73
|
-
|
|
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
|
-
/**
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
readonly webhookSecret
|
|
81
|
-
/**
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
|
|
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.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
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`.
|
|
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:
|
|
134
|
-
//
|
|
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
|
-
//
|
|
141
|
-
//
|
|
142
|
-
//
|
|
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:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|