UNPKG

acp-handler

Version:

Vercel handler for Agentic Commerce Protocol (ACP) - Build checkout APIs that AI agents like ChatGPT can use to complete purchases

580 lines (570 loc) 18.3 kB
import { assertFreshTimestamp, createOutboundWebhook, hmacSign, hmacVerify, timingSafeEqual } from "./outbound-DPQQzjHi.js"; import { AddressSchema, CompleteCheckoutSessionSchema, CreateCheckoutSessionSchema, MoneySchema, UpdateCheckoutSessionSchema, json, parseJSON, specError, validateBody } from "./schema-dfYBxw9A.js"; import { createHmac, timingSafeEqual as timingSafeEqual$1 } from "node:crypto"; import { createClient } from "redis"; //#region src/checkout/errors.ts function err(code, message, param, type = "invalid_request_error", status = 400) { return new Response(JSON.stringify({ error: { type, code, message, ...param ? { param } : {} } }), { status, headers: { "content-type": "application/json" } }); } function ok(data, { status = 200, echo } = {}) { const headers = new Headers({ "content-type": "application/json" }); if (echo) { for (const [k, v] of Object.entries(echo)) if (v) headers.set(k, String(v)); } return new Response(JSON.stringify(data), { status, headers }); } //#endregion //#region src/checkout/fsm.ts const ALLOWED = { not_ready_for_payment: ["ready_for_payment", "canceled"], ready_for_payment: ["completed", "canceled"], completed: [], canceled: [] }; function canTransition(from, to) { return ALLOWED[from].includes(to) ? true : { error: `cannot transition from "${from}" to "${to}"` }; } //#endregion //#region src/checkout/headers.ts const HEADERS = { AUTH: "authorization", IDEMPOTENCY: "idempotency-key", REQ_ID: "request-id", SIG: "signature", TS: "timestamp", API_VER: "api-version", UA: "user-agent", LANG: "accept-language" }; function parseHeaders(req) { const h = (name) => req.headers.get(name) ?? void 0; const ts = h(HEADERS.TS); return { auth: h(HEADERS.AUTH), idempotencyKey: h(HEADERS.IDEMPOTENCY), requestId: h(HEADERS.REQ_ID), signature: h(HEADERS.SIG), timestamp: ts ? Number(ts) : void 0, apiVersion: h(HEADERS.API_VER), userAgent: h(HEADERS.UA), acceptLanguage: h(HEADERS.LANG) }; } //#endregion //#region src/checkout/idempotency.ts async function withIdempotency(key, store, compute, { ttlSec = 3600, serialize = JSON.stringify, deserialize = JSON.parse } = {}) { if (!key) return { reused: false, value: await compute() }; const cached = await store.get(key); if (cached) return { reused: true, value: deserialize(cached) }; if (!await store.setnx(key, "__pending__", ttlSec)) { await new Promise((r) => setTimeout(r, 25)); const v = await store.get(key); if (v) return { reused: true, value: deserialize(v) }; } const value = await compute().catch(async (e) => { await store.setnx(`${key}:fail`, String(Date.now()), 60); throw e; }); await store.setnx(`${key}:set`, "1", 1); store.set?.(key, serialize(value), ttlSec); return { reused: false, value }; } //#endregion //#region src/checkout/signature.ts /** * Verifies the HMAC signature of an incoming request * * This prevents: * - Unauthorized requests (only OpenAI has the secret) * - Replay attacks (timestamp must be recent) * - Tampering (signature covers body + timestamp) * * @example * ```ts * const isValid = await verifySignature(req, { * secret: process.env.OPENAI_WEBHOOK_SECRET * }); * if (!isValid) { * return new Response('Unauthorized', { status: 401 }); * } * ``` */ async function verifySignature(req, config) { const signature = req.headers.get("signature"); const timestamp = req.headers.get("timestamp"); if (!signature || !timestamp) return false; const toleranceSec = config.toleranceSec ?? 300; const now = Math.floor(Date.now() / 1e3); const requestTime = Number.parseInt(timestamp, 10); if (Number.isNaN(requestTime)) return false; if (Math.abs(now - requestTime) > toleranceSec) return false; const body = await req.text(); const expected = computeSignature(body, timestamp, config.secret); try { return timingSafeEqual$1(Buffer.from(signature, "hex"), Buffer.from(expected, "hex")); } catch { return false; } } /** * Computes HMAC signature for a payload * * Format: HMAC-SHA256(timestamp.body, secret) * * @internal */ function computeSignature(body, timestamp, secret) { const payload = `${timestamp}.${body}`; return createHmac("sha256", secret).update(payload).digest("hex"); } /** * Middleware helper for signature verification * * @example * ```ts * const handlers = createHandlers( * { products, payments, webhooks }, * { * store, * signature: { * secret: process.env.OPENAI_WEBHOOK_SECRET, * toleranceSec: 300 * } * } * ); * ``` */ function createSignatureVerifier(config) { return { verify: (req) => verifySignature(req, config), config }; } //#endregion //#region src/checkout/storage.ts /** * Creates a Redis-backed session store. * This is the default session storage implementation. * * @param kv - Key-value store (Redis recommended) * @param ns - Namespace for session keys (default: "acp") * @returns SessionStore implementation * * @example * ```typescript * import { createStoreWithRedis, createRedisSessionStore } from 'acp-handler'; * * const { store } = createStoreWithRedis('acp'); * const sessions = createRedisSessionStore(store, 'acp'); * ``` */ function createRedisSessionStore(kv, ns = "acp") { const K = (id) => `${ns}:session:${id}`; return { async get(id) { const s = await kv.get(K(id)); return s ? JSON.parse(s) : null; }, async put(session, ttlSec = 24 * 3600) { await kv.set(K(session.id), JSON.stringify({ ...session, updated_at: (/* @__PURE__ */ new Date()).toISOString() }), ttlSec); } }; } /** * @deprecated Use createRedisSessionStore instead */ const sessionStore = createRedisSessionStore; //#endregion //#region src/checkout/tracing.ts const SpanStatusCode = { UNSET: 0, OK: 1, ERROR: 2 }; /** * Helper to trace async operations with OpenTelemetry. * If no tracer is provided, the operation runs without tracing overhead. */ async function traced(tracer, name, fn, attrs) { if (!tracer) return fn(); return tracer.startActiveSpan(name, { attributes: attrs }, async (span) => { try { const result = await fn(span); span.setStatus({ code: SpanStatusCode.OK }); span.end(); return result; } catch (e) { span.recordException(e); span.setStatus({ code: SpanStatusCode.ERROR }); span.end(); throw e; } }); } //#endregion //#region src/checkout/handlers.ts function createHandlers(handlers, options) { const { products, payments } = handlers; const sessions = handlers.sessions ?? createRedisSessionStore(options.store); const idempotency = options.store; const { tracer, signature } = options; /** * Verify request signature if configured * Returns error response if verification fails */ async function checkSignature(req) { if (!signature) return null; const cloned = req.clone(); if (!await verifySignature(cloned, signature)) return err("invalid_signature", "Request signature verification failed", void 0, "authentication_error", 401); return null; } return { create: async (req, body) => traced(tracer, "checkout.create", async (span) => { const sigErr = await checkSignature(req); if (sigErr) return sigErr; const H = parseHeaders(req); const idek = H.idempotencyKey; idek && span?.setAttribute("idempotency_key", idek); const compute = async () => { const quote = await traced(tracer, "products.price", () => products.price({ items: body.items, customer: body.customer, fulfillment: body.fulfillment }), { items_count: body.items.length.toString() }); const session = { id: crypto.randomUUID(), status: quote.ready ? "ready_for_payment" : "not_ready_for_payment", items: quote.items, totals: quote.totals, fulfillment: quote.fulfillment, customer: body.customer, messages: quote.messages, created_at: (/* @__PURE__ */ new Date()).toISOString(), updated_at: (/* @__PURE__ */ new Date()).toISOString(), links: {} }; span?.setAttribute("session_id", session.id); span?.setAttribute("session_status", session.status); await traced(tracer, "session.put", () => sessions.put(session), { session_id: session.id }); return session; }; const { reused, value } = await withIdempotency(idek, idempotency, compute); span?.setAttribute("idempotency_reused", reused.toString()); return ok(value, { status: reused ? 200 : 201, echo: { [HEADERS.IDEMPOTENCY]: idek, [HEADERS.REQ_ID]: H.requestId } }); }), update: async (req, id, body) => traced(tracer, "checkout.update", async (span) => { const sigErr = await checkSignature(req); if (sigErr) return sigErr; const H = parseHeaders(req); const idek = H.idempotencyKey; span?.setAttribute("session_id", id); idek && span?.setAttribute("idempotency_key", idek); const compute = async () => { const s = await traced(tracer, "session.get", () => sessions.get(id), { session_id: id }); if (!s) throw new Error(JSON.stringify({ code: "session_not_found", message: `Session "${id}" not found`, param: "checkout_session_id", type: "invalid_request_error" })); const items = body.items ?? s.items.map(({ id: id$1, quantity }) => ({ id: id$1, quantity })); const quote = await traced(tracer, "products.price", () => products.price({ items, customer: body.customer ?? s.customer, fulfillment: body.fulfillment ?? s.fulfillment }), { items_count: items.length.toString() }); const next = { ...s, items: quote.items, totals: quote.totals, fulfillment: quote.fulfillment, customer: body.customer ?? s.customer, messages: quote.messages, status: quote.ready ? s.status === "not_ready_for_payment" ? "ready_for_payment" : s.status : "not_ready_for_payment", updated_at: (/* @__PURE__ */ new Date()).toISOString() }; span?.setAttribute("session_status", next.status); await traced(tracer, "session.put", () => sessions.put(next), { session_id: next.id }); return next; }; try { const { reused, value } = await withIdempotency(idek, idempotency, compute); span?.setAttribute("idempotency_reused", reused.toString()); return ok(value, { status: 200, echo: { [HEADERS.IDEMPOTENCY]: idek, [HEADERS.REQ_ID]: H.requestId } }); } catch (e) { const parsed = JSON.parse(e.message); return err(parsed.code, parsed.message, parsed.param, parsed.type, 404); } }), complete: async (req, id, body) => traced(tracer, "checkout.complete", async (span) => { const sigErr = await checkSignature(req); if (sigErr) return sigErr; const H = parseHeaders(req); const idek = H.idempotencyKey; span?.setAttribute("session_id", id); idek && span?.setAttribute("idempotency_key", idek); const compute = async () => { const s = await traced(tracer, "session.get", () => sessions.get(id), { session_id: id }); if (!s) throw new Error(JSON.stringify({ code: "session_not_found", message: `Session "${id}" not found`, param: "checkout_session_id", type: "invalid_request_error" })); if (s.status !== "ready_for_payment") throw new Error(JSON.stringify({ code: "invalid_state", message: `Cannot complete from "${s.status}"`, param: "status", type: "invalid_request_error" })); const auth = await traced(tracer, "payments.authorize", () => payments.authorize({ session: s, delegated_token: body.payment?.delegated_token }), { session_id: s.id }); if (!auth.ok) throw new Error(JSON.stringify({ code: "payment_authorization_failed", message: auth.reason, type: "invalid_request_error" })); span?.setAttribute("payment_intent_id", auth.intent_id); const cap = await traced(tracer, "payments.capture", () => payments.capture(auth.intent_id), { intent_id: auth.intent_id }); if (!cap.ok) throw new Error(JSON.stringify({ code: "payment_capture_failed", message: cap.reason, type: "invalid_request_error" })); const can = canTransition(s.status, "completed"); if (can !== true) throw new Error(JSON.stringify({ code: "invalid_state", message: can.error, param: "status", type: "invalid_request_error" })); const completed = { ...s, status: "completed", updated_at: (/* @__PURE__ */ new Date()).toISOString() }; await traced(tracer, "session.put", () => sessions.put(completed), { session_id: completed.id }); const order = { id: auth.intent_id, checkout_session_id: s.id, status: "placed" }; return { ...completed, order }; }; try { const { reused, value } = await withIdempotency(idek, idempotency, compute); span?.setAttribute("idempotency_reused", reused.toString()); return ok(value, { status: 200, echo: { [HEADERS.IDEMPOTENCY]: idek, [HEADERS.REQ_ID]: H.requestId } }); } catch (e) { const parsed = JSON.parse(e.message); return err(parsed.code, parsed.message, parsed.param, parsed.type); } }), cancel: async (req, id) => traced(tracer, "checkout.cancel", async (span) => { const sigErr = await checkSignature(req); if (sigErr) return sigErr; const H = parseHeaders(req); const idek = H.idempotencyKey; span?.setAttribute("session_id", id); idek && span?.setAttribute("idempotency_key", idek); const compute = async () => { const s = await traced(tracer, "session.get", () => sessions.get(id), { session_id: id }); if (!s) throw new Error(JSON.stringify({ code: "session_not_found", message: `Session "${id}" not found`, param: "checkout_session_id", type: "invalid_request_error" })); const can = canTransition(s.status, "canceled"); if (can !== true) throw new Error(JSON.stringify({ code: "invalid_state", message: can.error, param: "status", type: "invalid_request_error" })); const next = { ...s, status: "canceled", updated_at: (/* @__PURE__ */ new Date()).toISOString() }; await traced(tracer, "session.put", () => sessions.put(next), { session_id: next.id }); return next; }; try { const { reused, value } = await withIdempotency(idek, idempotency, compute); span?.setAttribute("idempotency_reused", reused.toString()); return ok(value, { status: 200, echo: { [HEADERS.IDEMPOTENCY]: idek, [HEADERS.REQ_ID]: H.requestId } }); } catch (e) { const parsed = JSON.parse(e.message); return err(parsed.code, parsed.message, parsed.param, parsed.type, 404); } }), get: async (req, id) => traced(tracer, "checkout.get", async (span) => { const sigErr = await checkSignature(req); if (sigErr) return sigErr; const H = parseHeaders(req); span?.setAttribute("session_id", id); const s = await traced(tracer, "session.get", () => sessions.get(id), { session_id: id }); if (!s) return err("session_not_found", `Session "${id}" not found`, "checkout_session_id", "invalid_request_error", 404); span?.setAttribute("session_status", s.status); return ok(s, { status: 200, echo: { [HEADERS.REQ_ID]: H.requestId } }); }) }; } /** * Creates ACP handler with reusable utilities * * @example * ```typescript * import { acpHandler } from 'acp-handler'; * * export const acp = acpHandler({ * products, * payments, * store * }); * * // Use in route handler * export const { GET, POST } = acp.handlers; * * // Use webhooks from queue worker * await acp.webhooks.sendOrderUpdated(sessionId); * * // Use sessions from admin dashboard * const session = await acp.sessions.get(sessionId); * ``` */ function acpHandler(config) { const { products, payments, store, sessions: customSessions, tracer, signature } = config; const sessions = customSessions ?? createRedisSessionStore(store); return { handlers: createHandlers({ products, payments, sessions }, { store, tracer, signature }), webhooks: { async sendOrderUpdated(sessionId, webhookConfig) { const session = await sessions.get(sessionId); if (!session) throw new Error(`Session "${sessionId}" not found`); const { createOutboundWebhook: createOutboundWebhook$1 } = await import("./outbound-Cq2SIWPy.js"); await createOutboundWebhook$1({ webhookUrl: webhookConfig.webhookUrl, secret: webhookConfig.secret, merchantName: webhookConfig.merchantName }).orderUpdated({ checkout_session_id: sessionId, status: webhookConfig.status ?? session.status, order: webhookConfig.order }); } }, sessions: { get: (id) => sessions.get(id), put: (session, ttlSec) => sessions.put(session, ttlSec) } }; } //#endregion //#region src/checkout/storage/redis.ts /** * Redis implementation of the KV interface for storing checkout sessions */ function createRedisKV(client, namespace = "") { const redis = client ?? createClient({ url: process.env.REDIS_URL ?? "" }); const key = (k) => namespace ? `${namespace}:${k}` : k; const ensureConnected = async () => { if (!redis.isOpen) await redis.connect(); }; return { async get(k) { await ensureConnected(); return await redis.get(key(k)); }, async set(k, v, ttlSec) { await ensureConnected(); if (ttlSec) await redis.set(key(k), v, { EX: ttlSec }); else await redis.set(key(k), v); }, async setnx(k, v, ttlSec) { await ensureConnected(); if (ttlSec) return await redis.set(key(k), v, { NX: true, EX: ttlSec }) === "OK"; return await redis.setNX(key(k), v) === 1; } }; } /** * Helper function for setting up a store with Redis */ function createStoreWithRedis(namespace = "acp", client) { return { store: createRedisKV(client, namespace) }; } //#endregion export { AddressSchema, CompleteCheckoutSessionSchema, CreateCheckoutSessionSchema, HEADERS, MoneySchema, UpdateCheckoutSessionSchema, acpHandler, assertFreshTimestamp, canTransition, computeSignature, createHandlers, createOutboundWebhook, createRedisSessionStore, createSignatureVerifier, createStoreWithRedis, err, hmacSign, hmacVerify, json, ok, parseHeaders, parseJSON, sessionStore, specError, timingSafeEqual, validateBody, verifySignature }; //# sourceMappingURL=index.js.map