From f95288d9cd0e25f1a71f7385acc4b9af4a13ed64 Mon Sep 17 00:00:00 2001 From: Vedanshu Date: Tue, 22 Sep 2026 12:21:42 +0000 Subject: [PATCH] [Plugin] Wire the Stripe payment gateway into the in-process commerce client make-commerce-client.ts wired x402 into InProcessCommerceClient's payment gateways at the work-order-02 fold-in but never wired stripe -- the only payment method storefront checkout actually requests (checkout-routes.ts's PAYMENT_METHOD constant). Every card checkout resolved gateways.stripe to undefined and createOrderFromCart threw before returning a typed reason, surfacing as an opaque RENDER_FAILED instead of a real PaymentIntent. Added payments/stripe-wiring.ts, mirroring x402-wiring.ts's pattern, using the already-built @otta-sh/payments-stripe adapter (webhooks/stripe-settle- route.ts already constructed one to verify inbound webhooks; nothing ever constructed one to create a PaymentIntent). Fail-closed on BOTH settings:stripeSecretKey and settings:stripeWebhookSecret together -- a gateway that can take a live payment but can never verify its confirmation (or the reverse) is a half-armed state worse than off. api.stripe.com needed no allowedHosts change; it is the one constant STRIPE_API_HOST always grants. Updated make-commerce-client.test.ts's kv-read assertion: Stripe has no build-time gate the way x402's facilitator URL does, so resolving it means reading both its kv keys on every construction now, not zero. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01HPywdmMJ9B4V1ME6GrxuDu --- .changeset/stripe-in-process-gateway.md | 9 ++ .../src/commerce/make-commerce-client.ts | 21 ++-- packages/plugin/src/payments/stripe-wiring.ts | 72 +++++++++++ .../plugin/test/make-commerce-client.test.ts | 22 ++-- packages/plugin/test/stripe-wiring.test.ts | 117 ++++++++++++++++++ 5 files changed, 225 insertions(+), 16 deletions(-) create mode 100644 .changeset/stripe-in-process-gateway.md create mode 100644 packages/plugin/src/payments/stripe-wiring.ts create mode 100644 packages/plugin/test/stripe-wiring.test.ts diff --git a/.changeset/stripe-in-process-gateway.md b/.changeset/stripe-in-process-gateway.md new file mode 100644 index 0000000..a9263d2 --- /dev/null +++ b/.changeset/stripe-in-process-gateway.md @@ -0,0 +1,9 @@ +--- +"@otta-sh/plugin": minor +--- + +Wire the Stripe payment gateway into the in-process commerce composition root. + +`make-commerce-client.ts` wired `x402` into `InProcessCommerceClient`'s payment gateways at the work-order-02 fold-in, but never wired `stripe` — the only payment method the storefront checkout actually requests (`checkout-routes.ts`'s `PAYMENT_METHOD` constant). Every card checkout resolved to no gateway and threw before the domain could return a typed reason, surfacing as an opaque failure page instead of a real Stripe PaymentIntent. + +Added `payments/stripe-wiring.ts` (mirroring the existing `x402-wiring.ts` pattern) using the already-built `@otta-sh/payments-stripe` adapter. Fail-closed on both `settings:stripeSecretKey` and `settings:stripeWebhookSecret` together — a gateway that could create a live PaymentIntent but never verify its confirmation (or the reverse) is a half-armed state worse than off. `api.stripe.com` needed no new `allowedHosts` entry; it's the one constant the descriptor always grants. diff --git a/packages/plugin/src/commerce/make-commerce-client.ts b/packages/plugin/src/commerce/make-commerce-client.ts index f9ed934..6e883be 100644 --- a/packages/plugin/src/commerce/make-commerce-client.ts +++ b/packages/plugin/src/commerce/make-commerce-client.ts @@ -17,6 +17,7 @@ */ import { IN_PROCESS_EGRESS_URLS } from "../manifest.js"; +import { stripeGatewayFromCtx } from "../payments/stripe-wiring.js"; import { x402GatewayFromCtx } from "../payments/x402-wiring.js"; import type { CommerceClient } from "../product-commerce/commerce-client.js"; import type { PluginContext } from "../types.js"; @@ -39,13 +40,19 @@ export async function makeCommerceClient(ctx: PluginContext): Promise fetchImpl(String(input), init); +} + +/** + * Resolve the Stripe gateway for a context, or report `undefined` for + * "Stripe is not configured on this deployment" — the same fail-closed shape + * `x402GatewayFromCtx` uses. `createOrderFromCart` (`@otta-sh/domain`) refuses + * a `paymentMethod` with no gateway before touching the cart, so an + * unconfigured deployment gets a typed, loud refusal rather than a thrown + * error. + */ +export async function stripeGatewayFromCtx( + ctx: PluginContext, +): Promise { + const [secretKey, webhookSecret] = await Promise.all([ + stripeSecretKeyFromKv(ctx), + stripeWebhookSecretFromKv(ctx), + ]); + if (secretKey === undefined || webhookSecret === undefined) return undefined; + return new StripePaymentGateway({ + secretKey, + webhookSecret, + fetch: toGlobalFetch(ctx.http.fetch), + }); +} diff --git a/packages/plugin/test/make-commerce-client.test.ts b/packages/plugin/test/make-commerce-client.test.ts index df06ca0..19c8c2e 100644 --- a/packages/plugin/test/make-commerce-client.test.ts +++ b/packages/plugin/test/make-commerce-client.test.ts @@ -87,22 +87,26 @@ function makeCtx(seed: Record = {}): { } describe("makeCommerceClient", () => { - test("returns the in-process client, and reads NO credential from kv", async () => { + test("returns the in-process client, reading only the credentials Stripe always needs", async () => { // SEEDED, so a read would be a read of something real: if the composition - // root ever starts reaching for a payment credential merely to build a - // client, the recorded key names it. + // root ever starts reaching for a credential it should not, the recorded + // key names it. const { ctx, kvReads } = makeCtx({ - [STRIPE_SECRET_KEY_KEY]: "sk_test_NEVER_READ", - [STRIPE_WEBHOOK_SECRET_KEY]: "whsec_NEVER_READ", + [STRIPE_SECRET_KEY_KEY]: "sk_test_READ", + [STRIPE_WEBHOOK_SECRET_KEY]: "whsec_READ", [EMAIL_API_KEY_KEY]: "email_NEVER_READ", [X402_FACILITATOR_API_KEY_KEY]: "x402_NEVER_READ", }); const client = await makeCommerceClient(ctx); expect(client).toBeInstanceOf(InProcessCommerceClient); - // There is no service left to authenticate to, and the x402 wiring - // short-circuits on an unconfigured facilitator URL BEFORE it touches kv — - // so construction is credential-free, and an eager read fails here. - expect(kvReads).toEqual([]); + // Stripe (`stripe-wiring.ts`) has no build-time gate the way x402's + // facilitator URL does, so resolving whether it is configured means + // reading BOTH its kv keys on every construction — that is the two reads + // below, in the order `stripeGatewayFromCtx` issues them. The x402 wiring + // still short-circuits on an unconfigured facilitator URL BEFORE it + // touches kv, so neither `EMAIL_API_KEY_KEY` nor + // `X402_FACILITATOR_API_KEY_KEY` is read here. + expect(kvReads).toEqual([STRIPE_SECRET_KEY_KEY, STRIPE_WEBHOOK_SECRET_KEY]); }); test("the client spans the whole port — 25 methods, none of them a stub's", async () => { diff --git a/packages/plugin/test/stripe-wiring.test.ts b/packages/plugin/test/stripe-wiring.test.ts new file mode 100644 index 0000000..b59011f --- /dev/null +++ b/packages/plugin/test/stripe-wiring.test.ts @@ -0,0 +1,117 @@ +/** + * Stripe payment gateway, in-process — the missing half of INC-C5's pattern. + * + * See `src/payments/stripe-wiring.ts`'s module doc for why BOTH + * `settings:stripeSecretKey` and `settings:stripeWebhookSecret` are required + * before a gateway is wired at all, mirroring `x402-wiring.test.ts`'s + * fail-closed shape. + */ +import { + cents, + currency as toCurrency, + idempotencyKey as toIdempotencyKey, + orderId as toOrderId, +} from "@otta-sh/domain"; +import { describe, expect, test } from "vitest"; +import { STRIPE_SECRET_KEY_KEY, STRIPE_WEBHOOK_SECRET_KEY } from "../src/payment-secrets.js"; +import { stripeGatewayFromCtx } from "../src/payments/stripe-wiring.js"; +import type { PluginContext } from "../src/types.js"; + +const SECRET_KEY = "sk_test_abc123"; +const WEBHOOK_SECRET = "whsec_abc123"; + +function makeCtx( + seed: Record = {}, + failingKeys: ReadonlySet = new Set(), +): { ctx: PluginContext; calls: Array<{ url: string; init: RequestInit | undefined }> } { + const kv = new Map(Object.entries(seed)); + const calls: Array<{ url: string; init: RequestInit | undefined }> = []; + const ctx: PluginContext = { + http: { + fetch: (url: string, init?: RequestInit) => { + calls.push({ url, init }); + return Promise.resolve( + new Response(JSON.stringify({ id: "pi_test", client_secret: "pi_test_secret" }), { + status: 200, + }), + ); + }, + }, + kv: { + async get(k: string): Promise { + if (failingKeys.has(k)) throw new Error(`kv unavailable: ${k}`); + return kv.has(k) ? (kv.get(k) as T) : null; + }, + async set(k: string, v: unknown): Promise { + kv.set(k, v); + }, + async delete(k: string): Promise { + return kv.delete(k); + }, + async list(): Promise> { + return [...kv].map(([key, value]) => ({ key, value })); + }, + }, + }; + return { ctx, calls }; +} + +describe("stripeGatewayFromCtx", () => { + test("neither secret configured ⇒ no gateway", async () => { + const { ctx } = makeCtx(); + expect(await stripeGatewayFromCtx(ctx)).toBeUndefined(); + }); + + test("only the secret key ⇒ no gateway (a live intent nothing could ever verify)", async () => { + const { ctx } = makeCtx({ [STRIPE_SECRET_KEY_KEY]: SECRET_KEY }); + expect(await stripeGatewayFromCtx(ctx)).toBeUndefined(); + }); + + test("only the webhook secret ⇒ no gateway (verification with nothing that can create)", async () => { + const { ctx } = makeCtx({ [STRIPE_WEBHOOK_SECRET_KEY]: WEBHOOK_SECRET }); + expect(await stripeGatewayFromCtx(ctx)).toBeUndefined(); + }); + + test("both configured ⇒ a refundable stripe gateway", async () => { + const { ctx } = makeCtx({ + [STRIPE_SECRET_KEY_KEY]: SECRET_KEY, + [STRIPE_WEBHOOK_SECRET_KEY]: WEBHOOK_SECRET, + }); + const gateway = await stripeGatewayFromCtx(ctx); + expect(gateway?.id).toBe("stripe"); + expect(gateway?.refundable).toBe(true); + }); + + test("a kv rejection on either key degrades to no gateway, never a thrown route", async () => { + const { ctx } = makeCtx( + { [STRIPE_SECRET_KEY_KEY]: SECRET_KEY, [STRIPE_WEBHOOK_SECRET_KEY]: WEBHOOK_SECRET }, + new Set([STRIPE_SECRET_KEY_KEY]), + ); + expect(await stripeGatewayFromCtx(ctx)).toBeUndefined(); + }); + + test("createIntent's live call goes over ctx.http, not the global fetch", async () => { + const { ctx, calls } = makeCtx({ + [STRIPE_SECRET_KEY_KEY]: SECRET_KEY, + [STRIPE_WEBHOOK_SECRET_KEY]: WEBHOOK_SECRET, + }); + const gateway = await stripeGatewayFromCtx(ctx); + const handle = await gateway?.createIntent({ + orderId: toOrderId("11111111-1111-4111-8111-111111111111"), + amount: cents(2599), + currency: toCurrency("USD"), + idempotencyKey: toIdempotencyKey("idem_1"), + lines: [], + }); + expect(handle?.clientAction).toEqual({ + kind: "stripe_client_secret", + clientSecret: "pi_test_secret", + }); + expect(calls).toHaveLength(1); + expect(calls[0]?.url).toContain("api.stripe.com"); + expect( + ((calls[0]?.init?.headers ?? {}) as Record)["authorization"] ?? + ((calls[0]?.init?.headers ?? {}) as Record)["Authorization"], + ).toContain(SECRET_KEY); + }); +});