@cosmicdrift/kumiko-bundled-features 0.51.0 → 0.52.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.51.0",
3
+ "version": "0.52.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 (v2): api-key/webhook-secret kommen ZUR LAUFZEIT aus
22
- // secrets, billing-live aus config — daher harte deps auf beide.
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: SecretKeyHandle = { name: "subscription-stripe:secret:api-key" };
19
- const WEBHOOK_SECRET_HANDLE: SecretKeyHandle = {
20
- name: "subscription-stripe:secret:webhook-secret",
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. Ignoriert auditCtx (irrelevant für die Resolution). */
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.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";
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
- // 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",
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
- // Secret- + config-key short-names. Qualified zu `subscription-stripe:<name>`
10
- // (secrets) bzw. `subscription-stripe:config:<name>` (config) beim
11
- // registry-build.
12
- export const STRIPE_API_KEY_SECRET = "api-key" as const;
13
- export const STRIPE_WEBHOOK_SECRET_SECRET = "webhook-secret" as 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: 2
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 (v2):** Stripe-credentials + der billing-live-Master-
7
- // Switch kommen ZUR LAUFZEIT aus config/secrets, nicht mehr aus einem
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` → **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).
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
- STRIPE_API_KEY_SECRET,
42
+ STRIPE_API_KEY_CONFIG,
41
43
  STRIPE_BILLING_LIVE_CONFIG,
42
44
  STRIPE_PROVIDER_NAME,
43
- STRIPE_WEBHOOK_SECRET_SECRET,
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` (system-secret). Gesetzt nur
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` (system-secret). */
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
- "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).",
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 (api-key/webhook-secret).
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-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.",
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
- const configKeys = r.config({
158
+ r.translations({
148
159
  keys: {
149
- [STRIPE_BILLING_LIVE_CONFIG]: createSystemConfig("boolean", { default: false }),
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: apiKeySecret,
155
- webhookSecretHandle: webhookSecret,
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 { apiKeySecret, webhookSecret, configKeys };
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 the **secrets** feature, stored
9
- // under SYSTEM_TENANT_ID (Stripe is app-wide, not per-tenant secrets
10
- // v1 only declares `scope:"tenant"`, so app-wide values live under the
11
- // system tenant, the same convention the config-resolver uses for
12
- // system-scope rows).
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 system-scoped Stripe API key via secrets:write:set (or seed it from STRIPE_API_KEY during the env bridge).";
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: SecretKeyHandle;
61
- readonly webhookSecretHandle: SecretKeyHandle;
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: SecretKeyHandle,
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;