Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/stripe-in-process-gateway.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 14 additions & 7 deletions packages/plugin/src/commerce/make-commerce-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -39,13 +40,19 @@ export async function makeCommerceClient(ctx: PluginContext): Promise<CommerceCl
// The payment gateways the service used to wire from env are wired HERE,
// because resolving them is asynchronous (kv) and the client's constructor is
// not. INC-C5 wires x402, whose facilitator call goes over `ctx.http` to the
// host `allowedHosts` already grants; an unconfigured deployment gets
// `undefined` and therefore an EMPTY map, which the domain refuses loudly
// rather than minting an unpayable order.
const x402 = await x402GatewayFromCtx(ctx, {
facilitatorUrl: IN_PROCESS_EGRESS_URLS.facilitatorUrl,
});
// host `allowedHosts` already grants; `stripe-wiring.ts` wires the other half
// storefront checkout actually uses (`PAYMENT_METHOD` in
// `checkout-routes.ts`). Each resolves independently to `undefined` on an
// unconfigured deployment and is simply omitted from the map, which the
// domain refuses loudly rather than minting an unpayable order.
const [x402, stripe] = await Promise.all([
x402GatewayFromCtx(ctx, { facilitatorUrl: IN_PROCESS_EGRESS_URLS.facilitatorUrl }),
stripeGatewayFromCtx(ctx),
]);
return new InProcessCommerceClient(ctx, {
gateways: x402 === undefined ? {} : { x402 },
gateways: {
...(x402 === undefined ? {} : { x402 }),
...(stripe === undefined ? {} : { stripe }),
},
});
}
72 changes: 72 additions & 0 deletions packages/plugin/src/payments/stripe-wiring.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Stripe payment gateway, in-process (work order 02 follow-up).
*
* WHAT WAS MISSING. `make-commerce-client.ts` wired `x402` into
* `InProcessCommerceClient`'s `gateways` map (INC-C5) but never wired
* `stripe` — `@otta-sh/payments-stripe` ships a complete `PaymentGateway`
* adapter (`StripePaymentGateway`) and `webhooks/stripe-settle-route.ts`
* already constructs one to VERIFY inbound webhooks, but nothing ever
* constructed one for `createOrder` to CREATE a PaymentIntent with. Every
* `paymentMethod: "stripe"` checkout (`checkout-routes.ts`'s `PAYMENT_METHOD`
* constant — the only method the storefront checkout ever requests) resolved
* `deps.gateways.stripe` to `undefined` and threw before `createOrderFromCart`
* could return a typed reason, surfacing as an opaque `RENDER_FAILED`. This
* module is the missing half.
*
* FAIL-CLOSED ON BOTH SECRETS, not just `secretKey`. `StripePaymentGateway`'s
* `webhookSecret` is a MANDATORY constructor field (it throws on an empty
* one), so a gateway cannot exist without it regardless — but this module
* requires `secretKey` too, deliberately, even though `createIntent` would
* happily fall back to the OFFLINE deterministic handle without one. Wiring a
* gateway that can take a buyer's live PaymentIntent (secretKey present) but
* whose confirmation can never be verified (webhookSecret absent) — or the
* mirror, a webhook verifier with no way to have created what it is
* confirming — is a half-armed state worse than off: an order stuck holding
* stock against a payment nothing can ever settle. Both configured, or no
* gateway at all, exactly like `x402GatewayFromCtx` (`payments/x402-wiring.ts`)
* refuses to arm on a partial config.
*
* `api.stripe.com` needs no `allowedHosts` wiring here — it is the one
* constant entry `resolveAllowedHosts` always grants (`manifest.ts`,
* `STRIPE_API_HOST`), unlike x402's deployment-supplied facilitator URL.
*/

import { StripePaymentGateway } from "@otta-sh/payments-stripe";
import { stripeSecretKeyFromKv, stripeWebhookSecretFromKv } from "../payment-secrets.js";
import type { PluginContext } from "../types.js";

/**
* `ctx.http.fetch` takes a `string` url; the global `fetch` type the gateway's
* transport is declared against accepts `RequestInfo | URL` (Stripe's own
* transport, `createStripeHttpTransport`, only ever calls it with a plain
* string it built itself — see that function's body). `String(...)` on a
* `string` or a `URL` yields the same url either way; a `Request` object is
* never passed in practice, so this adapter exists purely to satisfy the
* wider declared type, not to handle a shape that occurs.
*/
function toGlobalFetch(fetchImpl: PluginContext["http"]["fetch"]): typeof fetch {
return (input, init) => 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<StripePaymentGateway | undefined> {
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),
});
}
22 changes: 13 additions & 9 deletions packages/plugin/test/make-commerce-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,22 +87,26 @@ function makeCtx(seed: Record<string, string> = {}): {
}

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 () => {
Expand Down
117 changes: 117 additions & 0 deletions packages/plugin/test/stripe-wiring.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {},
failingKeys: ReadonlySet<string> = new Set(),
): { ctx: PluginContext; calls: Array<{ url: string; init: RequestInit | undefined }> } {
const kv = new Map<string, unknown>(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<T>(k: string): Promise<T | null> {
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<void> {
kv.set(k, v);
},
async delete(k: string): Promise<boolean> {
return kv.delete(k);
},
async list(): Promise<Array<{ key: string; value: unknown }>> {
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<string, string>)["authorization"] ??
((calls[0]?.init?.headers ?? {}) as Record<string, string>)["Authorization"],
).toContain(SECRET_KEY);
});
});
Loading