@suluk/stripe 0.1.5 → 0.1.7
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/index.ts +4 -0
- package/src/rest.ts +76 -0
- package/src/webhook-verify.ts +40 -0
- package/src/webhook.ts +3 -0
- package/test/rest.test.ts +47 -0
- package/test/webhook-verify.test.ts +58 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/stripe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "First-class Stripe via a swappable PaymentProvider: usage-based billing (Billing Meters + meter events), customers, metered prices, subscriptions, webhooks — and a bridge from @suluk/cost to Stripe usage. Other processors follow Stripe. CANDIDATE tooling.",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
package/src/index.ts
CHANGED
|
@@ -31,3 +31,7 @@ export {
|
|
|
31
31
|
type StripeCheckoutLike, type PaymentMethodLike, type PaymentIntentLike,
|
|
32
32
|
} from "./checkout";
|
|
33
33
|
export { webhookRouter, STRIPE_EVENTS, type WebhookRouter, type WebhookHandler, type HandleResult } from "./webhook";
|
|
34
|
+
// SDK-free edge webhook verification (Web Crypto) — one verifier for dev + Workers prod, no `stripe` SDK.
|
|
35
|
+
export { verifyStripeSignature, timingSafeHexEqual, type VerifyOptions } from "./webhook-verify";
|
|
36
|
+
// the StripeLike fetch impl (Workers-safe) + read surface for webhook PI→order resolution + refund checks.
|
|
37
|
+
export { restStripe, toForm, stripeGet, retrievePaymentIntent, type RestStripeOptions } from "./rest";
|
package/src/rest.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A {@link StripeLike} client over the Stripe REST API (fetch only — no SDK), so the @suluk/stripe helpers
|
|
3
|
+
* (setupUsageBilling / stripeProvider / reportCostUsage / subscriptions) run on a Cloudflare Worker or any fetch
|
|
4
|
+
* runtime. Stripe's API is form-encoded with bracket notation for nested params; {@link toForm} flattens the param
|
|
5
|
+
* objects the helpers build. Plus the read surface ({@link stripeGet} / {@link retrievePaymentIntent}) the edge needs
|
|
6
|
+
* for webhook PaymentIntent→order resolution and refund-state checks. `fetch` is injectable for testing.
|
|
7
|
+
*/
|
|
8
|
+
import type { StripeLike } from "./types";
|
|
9
|
+
|
|
10
|
+
type FetchLike = typeof fetch;
|
|
11
|
+
export interface RestStripeOptions { fetch?: FetchLike }
|
|
12
|
+
|
|
13
|
+
/** Flatten a params object into Stripe's bracket form-encoding (recurse objects + arrays). */
|
|
14
|
+
export function toForm(obj: Record<string, unknown>, prefix = "", form = new URLSearchParams()): URLSearchParams {
|
|
15
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
16
|
+
if (v == null) continue;
|
|
17
|
+
const key = prefix ? `${prefix}[${k}]` : k;
|
|
18
|
+
if (Array.isArray(v)) {
|
|
19
|
+
v.forEach((item, i) => {
|
|
20
|
+
if (item && typeof item === "object") toForm(item as Record<string, unknown>, `${key}[${i}]`, form);
|
|
21
|
+
else form.append(`${key}[${i}]`, String(item));
|
|
22
|
+
});
|
|
23
|
+
} else if (typeof v === "object") {
|
|
24
|
+
toForm(v as Record<string, unknown>, key, form);
|
|
25
|
+
} else {
|
|
26
|
+
form.append(key, String(v));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return form;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A duck-typed Stripe client backed by the REST API. `key` is the secret key. */
|
|
33
|
+
export function restStripe(key: string, opts: RestStripeOptions = {}): StripeLike {
|
|
34
|
+
const f = opts.fetch ?? fetch;
|
|
35
|
+
const post = async (path: string, params: Record<string, unknown>): Promise<any> => { // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
36
|
+
const res = await f(`https://api.stripe.com/v1/${path}`, {
|
|
37
|
+
method: "POST",
|
|
38
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/x-www-form-urlencoded" },
|
|
39
|
+
body: toForm(params).toString(),
|
|
40
|
+
});
|
|
41
|
+
const json = await res.json().catch(() => ({}));
|
|
42
|
+
if (!res.ok) throw new Error((json as { error?: { message?: string } })?.error?.message ?? `Stripe ${path} failed (${res.status})`);
|
|
43
|
+
return json;
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
customers: { create: (p) => post("customers", p) },
|
|
47
|
+
products: { create: (p) => post("products", p) },
|
|
48
|
+
prices: { create: (p) => post("prices", p) },
|
|
49
|
+
subscriptions: { create: (p) => post("subscriptions", p) },
|
|
50
|
+
billing: {
|
|
51
|
+
meters: { create: (p) => post("billing/meters", p) },
|
|
52
|
+
meterEvents: { create: (p) => post("billing/meter_events", p) },
|
|
53
|
+
},
|
|
54
|
+
billingPortal: { sessions: { create: (p) => post("billing_portal/sessions", p) } },
|
|
55
|
+
// verifyWebhook isn't used by the REST adapter (the edge uses verifyStripeSignature) — stub to satisfy the type.
|
|
56
|
+
webhooks: { constructEvent: () => { throw new Error("constructEvent not supported by the REST adapter; use verifyStripeSignature"); } },
|
|
57
|
+
} as StripeLike;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Low-level authenticated GET → parsed JSON (null on non-OK / parse error). */
|
|
61
|
+
export async function stripeGet<T = unknown>(key: string, path: string, opts: RestStripeOptions = {}): Promise<T | null> {
|
|
62
|
+
if (!key) return null;
|
|
63
|
+
const f = opts.fetch ?? fetch;
|
|
64
|
+
try {
|
|
65
|
+
const r = await f(`https://api.stripe.com/v1/${path}`, { headers: { authorization: `Bearer ${key}` } });
|
|
66
|
+
if (!r.ok) return null;
|
|
67
|
+
return (await r.json().catch(() => null)) as T | null;
|
|
68
|
+
} catch { return null; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Retrieve a PaymentIntent, optionally expanding fields (e.g. `latest_charge`). Returns null if unresolvable. */
|
|
72
|
+
export async function retrievePaymentIntent<T = Record<string, unknown>>(key: string, id: string, opts: RestStripeOptions & { expand?: string[] } = {}): Promise<T | null> {
|
|
73
|
+
if (!id) return null;
|
|
74
|
+
const q = (opts.expand ?? []).map((e) => `expand[]=${encodeURIComponent(e)}`).join("&");
|
|
75
|
+
return stripeGet<T>(key, `payment_intents/${encodeURIComponent(id)}${q ? "?" + q : ""}`, opts);
|
|
76
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SDK-free Stripe webhook signature verification (Web Crypto HMAC-SHA256) — runs on Workers / Bun / Node 18+ with
|
|
3
|
+
* NO `stripe` SDK. The edge leaf: prove a webhook payload is authentic AND fresh before dispatching it (e.g. via
|
|
4
|
+
* {@link webhookRouter}). `stripeProvider.verifyWebhook` (the SDK's constructEvent) stays for SDK callers; this is
|
|
5
|
+
* the binding-free path, so a dev server and a Workers prod runtime can share ONE verifier instead of diverging.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Constant-time hex-string compare (no early-out) — guards the signature check against timing oracles. */
|
|
9
|
+
export function timingSafeHexEqual(a: string, b: string): boolean {
|
|
10
|
+
if (a.length !== b.length) return false;
|
|
11
|
+
let diff = 0;
|
|
12
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
13
|
+
return diff === 0;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface VerifyOptions {
|
|
17
|
+
/** current unix seconds (default `Date.now()/1000`) — injectable for tests + replay-window tuning. */
|
|
18
|
+
now?: () => number;
|
|
19
|
+
/** reject events whose timestamp is older than this many seconds (default 300 — Stripe's window). */
|
|
20
|
+
toleranceSec?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Verify a Stripe `stripe-signature` header against the raw request body + the endpoint signing secret.
|
|
25
|
+
* Returns true iff a v1 signature matches the HMAC of `${t}.${rawBody}` AND the timestamp is within tolerance.
|
|
26
|
+
* Pass the RAW (unparsed) body — re-serializing JSON changes the bytes and breaks the HMAC.
|
|
27
|
+
*/
|
|
28
|
+
export async function verifyStripeSignature(rawBody: string, sigHeader: string, secret: string, opts: VerifyOptions = {}): Promise<boolean> {
|
|
29
|
+
if (!rawBody || !sigHeader || !secret) return false;
|
|
30
|
+
const parts: Record<string, string> = {};
|
|
31
|
+
for (const p of sigHeader.split(",")) { const i = p.indexOf("="); if (i > 0 && !(p.slice(0, i) in parts)) parts[p.slice(0, i)] = p.slice(i + 1); } // split on the FIRST '=' only
|
|
32
|
+
const ts = Number(parts.t);
|
|
33
|
+
if (!parts.t || !parts.v1 || !Number.isFinite(ts)) return false;
|
|
34
|
+
const now = (opts.now ?? (() => Date.now() / 1000))();
|
|
35
|
+
if (Math.abs(now - ts) > (opts.toleranceSec ?? 300)) return false; // reject stale/replayed events (Stripe's 5-min window)
|
|
36
|
+
const keyData = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
37
|
+
const mac = await crypto.subtle.sign("HMAC", keyData, new TextEncoder().encode(`${ts}.${rawBody}`));
|
|
38
|
+
const expected = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
39
|
+
return timingSafeHexEqual(expected, parts.v1);
|
|
40
|
+
}
|
package/src/webhook.ts
CHANGED
|
@@ -41,9 +41,12 @@ export function webhookRouter(handlers: Record<string, WebhookHandler> = {}): We
|
|
|
41
41
|
|
|
42
42
|
/** The common Stripe checkout/billing event types (for discoverability + typo-safe registration). */
|
|
43
43
|
export const STRIPE_EVENTS = {
|
|
44
|
+
checkoutCompleted: "checkout.session.completed",
|
|
45
|
+
checkoutExpired: "checkout.session.expired",
|
|
44
46
|
paymentSucceeded: "payment_intent.succeeded",
|
|
45
47
|
paymentFailed: "payment_intent.payment_failed",
|
|
46
48
|
chargeRefunded: "charge.refunded",
|
|
49
|
+
disputeClosed: "charge.dispute.closed",
|
|
47
50
|
setupSucceeded: "setup_intent.succeeded",
|
|
48
51
|
subscriptionUpdated: "customer.subscription.updated",
|
|
49
52
|
subscriptionDeleted: "customer.subscription.deleted",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rest.ts — the Workers-safe StripeLike fetch impl. Pins toForm's bracket encoding (the load-bearing form-encoder
|
|
3
|
+
* the helpers rely on) + the read surface (retrievePaymentIntent) over an injected fetch.
|
|
4
|
+
*/
|
|
5
|
+
import { test, expect, describe } from "bun:test";
|
|
6
|
+
import { toForm, restStripe, retrievePaymentIntent, stripeGet } from "../src/index";
|
|
7
|
+
|
|
8
|
+
describe("toForm", () => {
|
|
9
|
+
test("flattens scalars, nested objects, and arrays into Stripe bracket notation", () => {
|
|
10
|
+
const f = toForm({ email: "a@b.com", metadata: { orderId: 7 }, items: [{ price: "p_1", qty: 2 }] });
|
|
11
|
+
expect(f.get("email")).toBe("a@b.com");
|
|
12
|
+
expect(f.get("metadata[orderId]")).toBe("7");
|
|
13
|
+
expect(f.get("items[0][price]")).toBe("p_1");
|
|
14
|
+
expect(f.get("items[0][qty]")).toBe("2");
|
|
15
|
+
});
|
|
16
|
+
test("drops null/undefined", () => {
|
|
17
|
+
const f = toForm({ a: 1, b: null, c: undefined });
|
|
18
|
+
expect(f.has("b")).toBe(false);
|
|
19
|
+
expect(f.has("c")).toBe(false);
|
|
20
|
+
expect(f.get("a")).toBe("1");
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("rest read surface (injected fetch)", () => {
|
|
25
|
+
const mockFetch = (status: number, body: unknown) => (async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
|
|
26
|
+
|
|
27
|
+
test("retrievePaymentIntent returns the parsed PI + builds the expand query", async () => {
|
|
28
|
+
let seenUrl = "";
|
|
29
|
+
const fetch = (async (url: string) => { seenUrl = url; return new Response(JSON.stringify({ id: "pi_1", metadata: { orderId: "42" } }), { status: 200 }); }) as unknown as typeof fetch;
|
|
30
|
+
const pi = await retrievePaymentIntent<{ metadata?: { orderId?: string } }>("sk_test", "pi_1", { expand: ["latest_charge"], fetch });
|
|
31
|
+
expect(pi?.metadata?.orderId).toBe("42");
|
|
32
|
+
expect(seenUrl).toContain("/payment_intents/pi_1");
|
|
33
|
+
expect(seenUrl).toContain("expand[]=latest_charge");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("stripeGet returns null on non-OK", async () => {
|
|
37
|
+
expect(await stripeGet("sk_test", "payment_intents/x", { fetch: mockFetch(404, { error: {} }) })).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("restStripe.customers.create posts form-encoded + parses the result", async () => {
|
|
41
|
+
let body = "";
|
|
42
|
+
const fetch = (async (_url: string, init: RequestInit) => { body = String(init.body); return new Response(JSON.stringify({ id: "cus_1" }), { status: 200 }); }) as unknown as typeof fetch;
|
|
43
|
+
const r = await restStripe("sk_test", { fetch }).customers.create({ email: "a@b.com" });
|
|
44
|
+
expect((r as { id: string }).id).toBe("cus_1");
|
|
45
|
+
expect(body).toContain("email=a%40b.com");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* verifyStripeSignature — the SDK-free Web-Crypto webhook verifier. Pins a known-good signature (computed with the
|
|
3
|
+
* same HMAC the verifier checks), a stale-timestamp rejection, and a tampered-body rejection. The constant-time
|
|
4
|
+
* compare must have no early-out. These pin the behavior BEFORE dev + prod adopt this one verifier.
|
|
5
|
+
*/
|
|
6
|
+
import { test, expect, describe } from "bun:test";
|
|
7
|
+
import { verifyStripeSignature, timingSafeHexEqual } from "../src/index";
|
|
8
|
+
|
|
9
|
+
const SECRET = "whsec_test_secret";
|
|
10
|
+
const BODY = JSON.stringify({ id: "evt_1", type: "checkout.session.completed", data: { object: { id: "cs_1" } } });
|
|
11
|
+
|
|
12
|
+
/** Build a valid `stripe-signature` header (t=<ts>,v1=<HMAC-SHA256(`${ts}.${body}`)>) the way Stripe does. */
|
|
13
|
+
async function sign(body: string, secret: string, ts: number): Promise<string> {
|
|
14
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
|
|
15
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${ts}.${body}`));
|
|
16
|
+
const v1 = [...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
return `t=${ts},v1=${v1}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("verifyStripeSignature", () => {
|
|
21
|
+
const now = () => 1_700_000_000; // fixed clock
|
|
22
|
+
|
|
23
|
+
test("accepts a fresh, correctly-signed payload", async () => {
|
|
24
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
25
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now })).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("rejects a stale timestamp beyond tolerance (replay)", async () => {
|
|
29
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000 - 600); // 10 min old, tolerance 300
|
|
30
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now })).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects a tampered body (HMAC mismatch)", async () => {
|
|
34
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
35
|
+
expect(await verifyStripeSignature(BODY + " ", sig, SECRET, { now })).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("rejects a wrong secret, a missing v1, and empty inputs", async () => {
|
|
39
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000);
|
|
40
|
+
expect(await verifyStripeSignature(BODY, sig, "whsec_wrong", { now })).toBe(false);
|
|
41
|
+
expect(await verifyStripeSignature(BODY, "t=1700000000", SECRET, { now })).toBe(false);
|
|
42
|
+
expect(await verifyStripeSignature("", sig, SECRET, { now })).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("honors an injected tolerance window", async () => {
|
|
46
|
+
const sig = await sign(BODY, SECRET, 1_700_000_000 - 60);
|
|
47
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now, toleranceSec: 30 })).toBe(false);
|
|
48
|
+
expect(await verifyStripeSignature(BODY, sig, SECRET, { now, toleranceSec: 120 })).toBe(true);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("timingSafeHexEqual", () => {
|
|
53
|
+
test("true on equal, false on differing same-length and differing-length", () => {
|
|
54
|
+
expect(timingSafeHexEqual("deadbeef", "deadbeef")).toBe(true);
|
|
55
|
+
expect(timingSafeHexEqual("deadbeef", "deadbef0")).toBe(false);
|
|
56
|
+
expect(timingSafeHexEqual("dead", "deadbeef")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
});
|