@cosmicdrift/kumiko-bundled-features 0.51.0 → 0.53.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/subscription-stripe/__tests__/feature.test.ts +3 -2
- package/src/subscription-stripe/__tests__/runtime.test.ts +12 -10
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +24 -12
- package/src/subscription-stripe/constants.ts +6 -5
- package/src/subscription-stripe/feature.ts +69 -50
- package/src/subscription-stripe/runtime.ts +29 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.53.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>",
|
|
@@ -18,8 +18,9 @@ describe("createSubscriptionStripeFeature — shape", () => {
|
|
|
18
18
|
test("requires billing-foundation + config + secrets (runtime-keys via config/secrets)", () => {
|
|
19
19
|
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
20
20
|
expect(feature.requires).toContain("billing-foundation");
|
|
21
|
-
// Drift-Pin (
|
|
22
|
-
// secrets, billing-live
|
|
21
|
+
// Drift-Pin (v3): api-key/webhook-secret sind config-Keys mit
|
|
22
|
+
// backing:"secrets" (Wert im secrets-Store), billing-live plain config —
|
|
23
|
+
// daher harte deps auf config UND secrets (Store + tenant_secrets-Tabelle).
|
|
23
24
|
expect(feature.requires).toContain("config");
|
|
24
25
|
expect(feature.requires).toContain("secrets");
|
|
25
26
|
});
|
|
@@ -5,19 +5,19 @@
|
|
|
5
5
|
// (das deckt der Integration-Test ab).
|
|
6
6
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
|
-
import type {
|
|
9
|
-
ConfigKeyHandle,
|
|
10
|
-
HandlerContext,
|
|
11
|
-
SecretKeyHandle,
|
|
12
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import type { ConfigKeyHandle, HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
9
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
14
10
|
import { createSecret, type SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
15
11
|
import Stripe from "stripe";
|
|
16
12
|
import { createStripeClientCache, createStripeRuntimes } from "../runtime";
|
|
17
13
|
|
|
18
|
-
const API_KEY_HANDLE:
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
const API_KEY_HANDLE: ConfigKeyHandle<"text"> = {
|
|
15
|
+
name: "subscription-stripe:config:api-key",
|
|
16
|
+
type: "text",
|
|
17
|
+
};
|
|
18
|
+
const WEBHOOK_SECRET_HANDLE: ConfigKeyHandle<"text"> = {
|
|
19
|
+
name: "subscription-stripe:config:webhook-secret",
|
|
20
|
+
type: "text",
|
|
21
21
|
};
|
|
22
22
|
const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
23
23
|
name: "subscription-stripe:config:billingLive",
|
|
@@ -25,14 +25,16 @@ const BILLING_LIVE_HANDLE: ConfigKeyHandle<"boolean"> = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
/** Stub-SecretsContext: liest aus einer in-memory-map, matcht per
|
|
28
|
-
* qualified-name.
|
|
28
|
+
* qualified-name. backing:"secrets" persistiert config-Werte JSON-
|
|
29
|
+
* serialisiert — der Stub spiegelt das, damit der Runtime-parseStoredSecret
|
|
30
|
+
* denselben Pfad nimmt wie gegen den echten Store. Ignoriert auditCtx. */
|
|
29
31
|
function stubSecrets(values: Record<string, string>): SecretsContext {
|
|
30
32
|
const nameOf = (k: string | { readonly name: string }): string =>
|
|
31
33
|
typeof k === "string" ? k : k.name;
|
|
32
34
|
return {
|
|
33
35
|
get: async (_tenantId, key) => {
|
|
34
36
|
const value = values[nameOf(key)];
|
|
35
|
-
return value === undefined ? undefined : createSecret(value);
|
|
37
|
+
return value === undefined ? undefined : createSecret(JSON.stringify(value));
|
|
36
38
|
},
|
|
37
39
|
has: async (_tenantId, key) => values[nameOf(key)] !== undefined,
|
|
38
40
|
set: async () => undefined,
|
|
@@ -45,10 +45,11 @@ import {
|
|
|
45
45
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
46
46
|
|
|
47
47
|
// Qualified-names der runtime-keys (drift-pin: müssen 1:1 dem entsprechen,
|
|
48
|
-
// was r.
|
|
49
|
-
//
|
|
50
|
-
|
|
51
|
-
const
|
|
48
|
+
// was r.config(...) im feature build qualifiziert — `subscription-stripe:
|
|
49
|
+
// config:<shortName>`, backing:"secrets"). Scenario 5 setzt sie via
|
|
50
|
+
// config:write:set + beweist die Resolution durch den Webhook.
|
|
51
|
+
const API_KEY_CONFIG_QN = "subscription-stripe:config:api-key";
|
|
52
|
+
const WEBHOOK_SECRET_CONFIG_QN = "subscription-stripe:config:webhook-secret";
|
|
52
53
|
|
|
53
54
|
// =============================================================================
|
|
54
55
|
// Setup
|
|
@@ -382,15 +383,26 @@ const SEEDED_API_KEY = "sk_test_runtime_seeded";
|
|
|
382
383
|
|
|
383
384
|
describe("scenario 5: runtime-secret resolution", () => {
|
|
384
385
|
test("seeded system-secret schlägt factory-fallback: sig gegen seeded secret → 200 + DB-row", async () => {
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
386
|
+
// Set beide Keys via config:write:set (backing:"secrets") als SystemAdmin
|
|
387
|
+
// — exakt was der abgeleitete Sysadmin-configEdit-Screen / der Bridge-Seed
|
|
388
|
+
// in prod dispatcht. Der Wert landet JSON-serialisiert envelope-encrypted
|
|
389
|
+
// im secrets-Store; der Webhook löst ihn via parseStoredSecret wieder auf.
|
|
390
|
+
// SEEDED_WEBHOOK_SECRET ≠ TEST_SECRET (der fallback).
|
|
391
|
+
const sysAdmin = createTestUser({
|
|
392
|
+
id: 9001,
|
|
393
|
+
tenantId: SYSTEM_TENANT_ID,
|
|
394
|
+
roles: ["SystemAdmin"],
|
|
393
395
|
});
|
|
396
|
+
await stack.http.writeOk(
|
|
397
|
+
"config:write:set",
|
|
398
|
+
{ key: API_KEY_CONFIG_QN, value: SEEDED_API_KEY, scope: "system" },
|
|
399
|
+
sysAdmin,
|
|
400
|
+
);
|
|
401
|
+
await stack.http.writeOk(
|
|
402
|
+
"config:write:set",
|
|
403
|
+
{ key: WEBHOOK_SECRET_CONFIG_QN, value: SEEDED_WEBHOOK_SECRET, scope: "system" },
|
|
404
|
+
sysAdmin,
|
|
405
|
+
);
|
|
394
406
|
|
|
395
407
|
const tenantStringId = testTenantId(4005);
|
|
396
408
|
const payload = JSON.stringify(
|
|
@@ -6,11 +6,12 @@ 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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
|
|
13
|
-
export const
|
|
9
|
+
// Config-key short-names, qualified to `subscription-stripe:config:<name>`
|
|
10
|
+
// at registry-build. api-key + webhook-secret declare backing:"secrets"
|
|
11
|
+
// (value lives envelope-encrypted in the secrets store under SYSTEM_TENANT_ID)
|
|
12
|
+
// but are addressed as config keys; billingLive is a plain system config flag.
|
|
13
|
+
export const STRIPE_API_KEY_CONFIG = "api-key" as const;
|
|
14
|
+
export const STRIPE_WEBHOOK_SECRET_CONFIG = "webhook-secret" as const;
|
|
14
15
|
export const STRIPE_BILLING_LIVE_CONFIG = "billingLive" as const;
|
|
15
16
|
|
|
16
17
|
// =============================================================================
|
|
@@ -1,17 +1,18 @@
|
|
|
1
|
-
// kumiko-feature-version:
|
|
1
|
+
// kumiko-feature-version: 3
|
|
2
2
|
//
|
|
3
3
|
// subscription-stripe — Stripe-Plugin für die billing-foundation
|
|
4
4
|
// Plugin-API.
|
|
5
5
|
//
|
|
6
|
-
// **Runtime-config (
|
|
7
|
-
// Switch kommen ZUR LAUFZEIT aus config
|
|
6
|
+
// **Runtime-config (v3):** Stripe-credentials + der billing-live-Master-
|
|
7
|
+
// Switch kommen ZUR LAUFZEIT aus dem config-Feature, nicht mehr aus einem
|
|
8
8
|
// mount-time-Closure. Damit lassen sich Keys rotieren und prod live-
|
|
9
|
-
// schalten ohne Redeploy.
|
|
10
|
-
// - `subscription-stripe:api-key` + `:webhook-secret` →
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
9
|
+
// schalten ohne Redeploy — und die Eingabe-Maske entsteht von selbst.
|
|
10
|
+
// - `subscription-stripe:config:api-key` + `:webhook-secret` → system
|
|
11
|
+
// config keys mit **backing:"secrets"**: der Wert lebt envelope-
|
|
12
|
+
// verschlüsselt im secrets-Store unter SYSTEM_TENANT_ID, adressiert
|
|
13
|
+
// wird er als config-Key. `mask` leitet den Sysadmin-configEdit-Screen
|
|
14
|
+
// + Settings-Hub-Nav ab — kein handgeschriebenes r.screen/r.nav in der
|
|
15
|
+
// App mehr (v2 hatte die Keys als `r.secret` + App-eigene Maske).
|
|
15
16
|
// - `subscription-stripe:config:billingLive` → **system config**
|
|
16
17
|
// (boolean, default false). Der Master-Switch: ohne ihn darf kein
|
|
17
18
|
// checkout eine Stripe-Session erzeugen (#104-Invariante, write-side
|
|
@@ -31,16 +32,17 @@
|
|
|
31
32
|
|
|
32
33
|
import type { SubscriptionProviderPlugin } from "@cosmicdrift/kumiko-bundled-features/billing-foundation";
|
|
33
34
|
import {
|
|
35
|
+
access,
|
|
34
36
|
createSystemConfig,
|
|
35
37
|
defineFeature,
|
|
36
38
|
type FeatureDefinition,
|
|
37
39
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
38
40
|
import { z } from "zod";
|
|
39
41
|
import {
|
|
40
|
-
|
|
42
|
+
STRIPE_API_KEY_CONFIG,
|
|
41
43
|
STRIPE_BILLING_LIVE_CONFIG,
|
|
42
44
|
STRIPE_PROVIDER_NAME,
|
|
43
|
-
|
|
45
|
+
STRIPE_WEBHOOK_SECRET_CONFIG,
|
|
44
46
|
SUBSCRIPTION_STRIPE_FEATURE,
|
|
45
47
|
} from "./constants";
|
|
46
48
|
import {
|
|
@@ -80,12 +82,12 @@ export const subscriptionStripeEnvSchema = z.object({
|
|
|
80
82
|
});
|
|
81
83
|
|
|
82
84
|
export type SubscriptionStripeOptions = {
|
|
83
|
-
/** Bridge-fallback webhook-secret. Optional: v2 liest aus
|
|
84
|
-
* `subscription-stripe:webhook-secret
|
|
85
|
-
* während der env→secrets-Übergangsphase / in Tests. */
|
|
85
|
+
/** Bridge-fallback webhook-secret. Optional: v2 liest aus dem
|
|
86
|
+
* `subscription-stripe:config:webhook-secret`-Key (backing:"secrets").
|
|
87
|
+
* Gesetzt nur während der env→secrets-Übergangsphase / in Tests. */
|
|
86
88
|
readonly webhookSecret?: string;
|
|
87
|
-
/** Bridge-fallback api-key. Optional: v2 liest aus
|
|
88
|
-
* `subscription-stripe:api-key
|
|
89
|
+
/** Bridge-fallback api-key. Optional: v2 liest aus dem
|
|
90
|
+
* `subscription-stripe:config:api-key`-Key (backing:"secrets"). */
|
|
89
91
|
readonly apiKey?: string;
|
|
90
92
|
/** Price-to-tier-Mapping. Plugin liest die price-id aus dem Stripe-event
|
|
91
93
|
* (subscription.items.data[0].price.id) und mappt auf einen tier-name.
|
|
@@ -94,11 +96,6 @@ export type SubscriptionStripeOptions = {
|
|
|
94
96
|
readonly priceToTier?: Readonly<Record<string, string>>;
|
|
95
97
|
};
|
|
96
98
|
|
|
97
|
-
const SECRET_REDACT = (plaintext: string): string =>
|
|
98
|
-
plaintext.length < 12
|
|
99
|
-
? "•".repeat(plaintext.length)
|
|
100
|
-
: `${plaintext.slice(0, 8)}...${plaintext.slice(-4)}`;
|
|
101
|
-
|
|
102
99
|
/**
|
|
103
100
|
* Factory für das subscription-stripe-feature. Mountet IMMER (kein
|
|
104
101
|
* key-presence-Guard mehr) — die Aktivität wird runtime über config/secrets
|
|
@@ -111,48 +108,70 @@ export function createSubscriptionStripeFeature(
|
|
|
111
108
|
): FeatureDefinition {
|
|
112
109
|
return defineFeature(SUBSCRIPTION_STRIPE_FEATURE, (r) => {
|
|
113
110
|
r.describe(
|
|
114
|
-
|
|
111
|
+
'Stripe payment provider plugin for `billing-foundation`. Reads its Stripe API key + webhook secret from system config keys with `backing:"secrets"` (envelope-encrypted in the secrets store under the system tenant) and a `billingLive` **system config** flag — all at runtime, so keys rotate and prod goes live without a redeploy. The `mask` on each key derives the sysadmin settings screen + nav, so no app wires a hand-written config UI. 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).',
|
|
115
112
|
);
|
|
116
|
-
// Hard-deps: billing-foundation (plugin-host) + config (billing-live
|
|
117
|
-
// + secrets (
|
|
113
|
+
// Hard-deps: billing-foundation (plugin-host) + config (billing-live +
|
|
114
|
+
// backing:"secrets" credentials) + secrets (the store the backing:secrets
|
|
115
|
+
// dispatch reads/writes + its tenant_secrets table).
|
|
118
116
|
r.requires("billing-foundation");
|
|
119
117
|
r.requires("config");
|
|
120
118
|
r.requires("secrets");
|
|
121
119
|
r.envSchema(subscriptionStripeEnvSchema);
|
|
122
120
|
|
|
123
|
-
// Runtime
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
121
|
+
// Runtime config. api-key + webhook-secret declare backing:"secrets" —
|
|
122
|
+
// the value lives envelope-encrypted in the secrets store under
|
|
123
|
+
// SYSTEM_TENANT_ID, while `mask` derives the sysadmin settings screen +
|
|
124
|
+
// Settings-Hub nav (no hand-written r.screen/r.nav in the consuming app).
|
|
125
|
+
// billingLive is the #104 master switch. The factory-fallback
|
|
126
|
+
// (options.apiKey/.webhookSecret) covers the env→secrets bridge + tests.
|
|
127
|
+
const configKeys = r.config({
|
|
128
|
+
keys: {
|
|
129
|
+
[STRIPE_API_KEY_CONFIG]: createSystemConfig("text", {
|
|
130
|
+
backing: "secrets",
|
|
131
|
+
// Prefix-Guard, deckungsgleich mit subscriptionStripeEnvSchema:
|
|
132
|
+
// fängt den Paste-the-wrong-field-Fehler (pk_/price_), ohne den
|
|
133
|
+
// base62-Body zu constrainen. Anchored, kein Backtracking → kein ReDoS.
|
|
134
|
+
pattern: { regex: "^(sk|rk)_(test|live)_" },
|
|
135
|
+
write: access.systemAdmin,
|
|
136
|
+
read: access.admin,
|
|
137
|
+
mask: { title: "subscription-stripe.api-key", icon: "key", order: 1 },
|
|
138
|
+
}),
|
|
139
|
+
[STRIPE_WEBHOOK_SECRET_CONFIG]: createSystemConfig("text", {
|
|
140
|
+
backing: "secrets",
|
|
141
|
+
pattern: { regex: "^whsec_" },
|
|
142
|
+
write: access.systemAdmin,
|
|
143
|
+
read: access.admin,
|
|
144
|
+
mask: { title: "subscription-stripe.webhook-secret", icon: "shield", order: 2 },
|
|
145
|
+
}),
|
|
146
|
+
[STRIPE_BILLING_LIVE_CONFIG]: createSystemConfig("boolean", {
|
|
147
|
+
default: false,
|
|
148
|
+
// privileged = ["system", "SystemAdmin"]: preserves the legacy
|
|
149
|
+
// writeAs(system-actor) flip path while the derived configEdit
|
|
150
|
+
// screen lets a human SystemAdmin toggle go-live directly.
|
|
151
|
+
write: access.privileged,
|
|
152
|
+
read: access.admin,
|
|
153
|
+
mask: { title: "subscription-stripe.billing-live", icon: "rocket", order: 3 },
|
|
154
|
+
}),
|
|
141
155
|
},
|
|
142
|
-
redact: SECRET_REDACT,
|
|
143
|
-
scope: "tenant",
|
|
144
|
-
required: false,
|
|
145
156
|
});
|
|
146
157
|
|
|
147
|
-
|
|
158
|
+
r.translations({
|
|
148
159
|
keys: {
|
|
149
|
-
|
|
160
|
+
"subscription-stripe.api-key": { de: "Stripe API Key", en: "Stripe API Key" },
|
|
161
|
+
"subscription-stripe.webhook-secret": {
|
|
162
|
+
de: "Stripe Webhook Secret",
|
|
163
|
+
en: "Stripe Webhook Secret",
|
|
164
|
+
},
|
|
165
|
+
"subscription-stripe.billing-live": {
|
|
166
|
+
de: "Stripe Billing live",
|
|
167
|
+
en: "Stripe Billing Live",
|
|
168
|
+
},
|
|
150
169
|
},
|
|
151
170
|
});
|
|
152
171
|
|
|
153
172
|
const runtimes = createStripeRuntimes({
|
|
154
|
-
apiKeyHandle:
|
|
155
|
-
webhookSecretHandle:
|
|
173
|
+
apiKeyHandle: configKeys[STRIPE_API_KEY_CONFIG],
|
|
174
|
+
webhookSecretHandle: configKeys[STRIPE_WEBHOOK_SECRET_CONFIG],
|
|
156
175
|
billingLiveHandle: configKeys[STRIPE_BILLING_LIVE_CONFIG],
|
|
157
176
|
fallback: {
|
|
158
177
|
...(options.apiKey !== undefined && { apiKey: options.apiKey }),
|
|
@@ -170,6 +189,6 @@ export function createSubscriptionStripeFeature(
|
|
|
170
189
|
};
|
|
171
190
|
r.useExtension("subscriptionProvider", STRIPE_PROVIDER_NAME, plugin);
|
|
172
191
|
|
|
173
|
-
return {
|
|
192
|
+
return { configKeys };
|
|
174
193
|
});
|
|
175
194
|
}
|
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
// `createSubscriptionStripeFeature({ apiKey, webhookSecret })`-mount-time.
|
|
6
6
|
// Rotating a key or flipping prod live then needed a redeploy. This module
|
|
7
7
|
// resolves both at CALL-time instead:
|
|
8
|
-
// - `api-key` + `webhook-secret` from
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
8
|
+
// - `api-key` + `webhook-secret` from system config keys with
|
|
9
|
+
// backing:"secrets" — the value lives in the secrets store under
|
|
10
|
+
// SYSTEM_TENANT_ID (Stripe is app-wide), JSON-serialized like any config
|
|
11
|
+
// value. Both read paths reach it via SecretsContext.get(config-QN) and
|
|
12
|
+
// parse the JSON back (parseStoredSecret).
|
|
13
13
|
// - `billing-live` from a **system config** key (default false) — the
|
|
14
14
|
// master switch that gates whether a checkout may create a live session.
|
|
15
15
|
//
|
|
@@ -29,16 +29,16 @@ import { requireSecretsContext } from "@cosmicdrift/kumiko-bundled-features/secr
|
|
|
29
29
|
import {
|
|
30
30
|
type ConfigKeyHandle,
|
|
31
31
|
type HandlerContext,
|
|
32
|
-
type SecretKeyHandle,
|
|
33
32
|
SYSTEM_TENANT_ID,
|
|
34
33
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
35
34
|
import { FeatureDisabledError, UnconfiguredError } from "@cosmicdrift/kumiko-framework/errors";
|
|
36
35
|
import type { SecretsContext } from "@cosmicdrift/kumiko-framework/secrets";
|
|
36
|
+
import { parseJsonOrThrow } from "@cosmicdrift/kumiko-framework/utils";
|
|
37
37
|
import Stripe from "stripe";
|
|
38
38
|
import { SUBSCRIPTION_STRIPE_FEATURE } from "./constants";
|
|
39
39
|
|
|
40
40
|
const API_KEY_HINT =
|
|
41
|
-
"Set the
|
|
41
|
+
"Set the Stripe API key via config:write:set on `subscription-stripe:config:api-key` (or seed it from STRIPE_API_KEY during the env bridge).";
|
|
42
42
|
|
|
43
43
|
/** Memoize Stripe clients by api-key string — a fresh client is built only
|
|
44
44
|
* when the key actually changes (rotation), so steady-state calls reuse one
|
|
@@ -57,12 +57,27 @@ export function createStripeClientCache(): (apiKey: string) => Stripe {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
export type StripeRuntimeDeps = {
|
|
60
|
-
readonly apiKeyHandle:
|
|
61
|
-
readonly webhookSecretHandle:
|
|
60
|
+
readonly apiKeyHandle: ConfigKeyHandle<"text">;
|
|
61
|
+
readonly webhookSecretHandle: ConfigKeyHandle<"text">;
|
|
62
62
|
readonly billingLiveHandle: ConfigKeyHandle<"boolean">;
|
|
63
63
|
readonly fallback: { readonly apiKey?: string; readonly webhookSecret?: string };
|
|
64
64
|
};
|
|
65
65
|
|
|
66
|
+
// backing:"secrets" config keys store their value JSON-serialized in the
|
|
67
|
+
// secrets store (config:write:set → JSON.stringify). A raw SecretsContext.get
|
|
68
|
+
// (the un-audited webhook path + the audited ctx read here) therefore returns
|
|
69
|
+
// the JSON-quoted string and must parse it back. The factory fallback is a
|
|
70
|
+
// plain env string and stays raw — so parsing is applied only to the store
|
|
71
|
+
// read, never to the fallback.
|
|
72
|
+
function parseStoredSecret(raw: string | undefined): string | undefined {
|
|
73
|
+
if (raw === undefined || raw.length === 0) return undefined;
|
|
74
|
+
// parseJsonOrThrow is the sanctioned safe-parse (same helper the config
|
|
75
|
+
// resolver's deserializeValue uses for this very value) — mirrors how the
|
|
76
|
+
// store round-trips a backing:"secrets" text value.
|
|
77
|
+
const parsed = parseJsonOrThrow<unknown>(raw, "subscription-stripe credential (backing:secrets)");
|
|
78
|
+
return typeof parsed === "string" && parsed.length > 0 ? parsed : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
66
81
|
/** Post-tenant runtime: used by checkout/portal/cancel which carry a full
|
|
67
82
|
* HandlerContext (audited secret reads, config-gate). */
|
|
68
83
|
export type StripeCtxRuntime = {
|
|
@@ -94,9 +109,9 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
|
94
109
|
if (ctx.secrets) {
|
|
95
110
|
const got = await requireSecretsContext(ctx, SUBSCRIPTION_STRIPE_FEATURE).get(
|
|
96
111
|
SYSTEM_TENANT_ID,
|
|
97
|
-
deps.apiKeyHandle,
|
|
112
|
+
deps.apiKeyHandle.name,
|
|
98
113
|
);
|
|
99
|
-
key = got?.reveal();
|
|
114
|
+
key = parseStoredSecret(got?.reveal());
|
|
100
115
|
}
|
|
101
116
|
if (!key && deps.fallback.apiKey && deps.fallback.apiKey.length > 0) {
|
|
102
117
|
key = deps.fallback.apiKey;
|
|
@@ -113,14 +128,14 @@ export function createStripeRuntimes(deps: StripeRuntimeDeps): StripeRuntimes {
|
|
|
113
128
|
|
|
114
129
|
async function rawRead(
|
|
115
130
|
systemSecrets: SecretsContext | undefined,
|
|
116
|
-
handle:
|
|
131
|
+
handle: ConfigKeyHandle<"text">,
|
|
117
132
|
fallback: string | undefined,
|
|
118
133
|
): Promise<string | undefined> {
|
|
119
134
|
if (systemSecrets) {
|
|
120
135
|
// No auditCtx → un-audited framework-internal read (every webhook would
|
|
121
136
|
// otherwise spam the audit trail). This is the sanctioned no-ctx path.
|
|
122
|
-
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle);
|
|
123
|
-
const value = got?.reveal();
|
|
137
|
+
const got = await systemSecrets.get(SYSTEM_TENANT_ID, handle.name);
|
|
138
|
+
const value = parseStoredSecret(got?.reveal());
|
|
124
139
|
if (value && value.length > 0) return value;
|
|
125
140
|
}
|
|
126
141
|
return fallback && fallback.length > 0 ? fallback : undefined;
|