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
JavaScript
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