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
2 changes: 1 addition & 1 deletion packages/sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "acp-handler",
"version": "0.0.0-alpha.9",
"version": "0.0.0-alpha.10",
"description": "Vercel handler for Agentic Commerce Protocol (ACP) - Build checkout APIs that AI agents like ChatGPT can use to complete purchases",
"license": "MIT",
"repository": {
Expand Down
4 changes: 4 additions & 0 deletions packages/sdk/src/checkout/fsm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const ALLOWED: Record<CheckoutSessionStatus, CheckoutSessionStatus[]> = {
canceled: [],
};

export function isTerminal(status: CheckoutSessionStatus): boolean {
return ALLOWED[status].length === 0;
}

export function canTransition(
from: CheckoutSessionStatus,
to: CheckoutSessionStatus,
Expand Down
12 changes: 11 additions & 1 deletion packages/sdk/src/checkout/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Tracer } from "@opentelemetry/api";
import { ACPError, isACPError } from "./errors.ts";
import { canTransition } from "./fsm.ts";
import { canTransition, isTerminal } from "./fsm.ts";
import { withIdempotency } from "./idempotency.ts";
import { HEADERS, parseHeaders } from "./lib/headers.ts";
import { err, ok } from "./lib/http.ts";
Expand Down Expand Up @@ -208,6 +208,16 @@ export function createHandlers(
status: 404,
});

// Terminal sessions are immutable records of what was (not) paid for
if (isTerminal(s.status))
throw new ACPError({
code: "invalid_state",
message: `Cannot update a session in state "${s.status}"`,
param: "status",
type: "invalid_request_error",
status: 400,
});

// Merge updates
const items =
body.items ?? s.items.map(({ id, quantity }) => ({ id, quantity }));
Expand Down
152 changes: 152 additions & 0 deletions packages/sdk/test/checkout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,158 @@ describe("Checkout Integration", () => {
});
});

describe("FSM enforcement on update (terminal states)", () => {
async function createSession() {
const createReq = createRequest("http://test/checkout_sessions", {
method: "POST",
body: { items: [{ id: "prod-1", quantity: 1 }] },
});
const createRes = await handlers.create(createReq, {
items: [{ id: "prod-1", quantity: 1 }],
});
return await createRes.json();
}

function updateAttack(sessionId: string) {
const body = {
items: [{ id: "prod-laptop", quantity: 100 }],
customer: {
billing_address: {
email: "[email protected]",
name: "Attacker",
line1: "1 Evil St",
city: "Nowhere",
postal_code: "00000",
country: "US",
},
},
};
const req = createRequest(`http://test/checkout_sessions/${sessionId}`, {
method: "POST",
body,
});
return handlers.update(req, sessionId, body);
}

it("should reject update on a completed session without re-pricing", async () => {
// Wrap products with a spy so we can assert price() is never reached
let priceCalls = 0;
const spiedProducts = {
price: async (input: Parameters<typeof products.price>[0]) => {
priceCalls++;
return products.price(input);
},
};
handlers = createHandlers(
{ products: spiedProducts, payments },
{ store },
);

const session = await createSession();
priceCalls = 0;

const completeReq = createRequest(
`http://test/checkout_sessions/${session.id}/complete`,
{
method: "POST",
body: { payment: { delegated_token: "tok_123" } },
},
);
await handlers.complete(completeReq, session.id, {
payment: { delegated_token: "tok_123" },
});

const updateRes = await updateAttack(session.id);

expect(updateRes.status).toBe(400);
const error = await updateRes.json();
expect(error.error).toMatchObject({
code: "invalid_state",
param: "status",
type: "invalid_request_error",
});

// Pricing must never run for a terminal session
expect(priceCalls).toBe(0);

// No additional payment activity
expect((payments as any)._calls.authorize).toBe(1);
expect((payments as any)._calls.capture).toBe(1);

// Stored session is unchanged
const getReq = createRequest(
`http://test/checkout_sessions/${session.id}`,
);
const getRes = await handlers.get(getReq, session.id);
const stored = await getRes.json();
expect(stored.status).toBe("completed");
expect(stored.items).toEqual(session.items);
expect(stored.totals).toEqual(session.totals);
expect(stored.customer).toEqual(session.customer);
});

it("should reject update on a canceled session", async () => {
const session = await createSession();

const cancelReq = createRequest(
`http://test/checkout_sessions/${session.id}/cancel`,
{ method: "POST" },
);
await handlers.cancel(cancelReq, session.id);

const updateRes = await updateAttack(session.id);

expect(updateRes.status).toBe(400);
const error = await updateRes.json();
expect(error.error).toMatchObject({
code: "invalid_state",
param: "status",
type: "invalid_request_error",
});

// Stored session is unchanged
const getReq = createRequest(
`http://test/checkout_sessions/${session.id}`,
);
const getRes = await handlers.get(getReq, session.id);
const stored = await getRes.json();
expect(stored.status).toBe("canceled");
expect(stored.items).toEqual(session.items);
expect(stored.totals).toEqual(session.totals);
});

it("should still allow update on a not_ready_for_payment session", async () => {
// Start not ready, then flip the quote to ready to exercise the status ladder
let ready = false;
const togglingProducts = {
price: async (input: Parameters<typeof products.price>[0]) => {
const quote = await products.price(input);
return { ...quote, ready };
},
};
handlers = createHandlers(
{ products: togglingProducts, payments },
{ store },
);

const session = await createSession();
expect(session.status).toBe("not_ready_for_payment");

ready = true;
const body = { items: [{ id: "prod-1", quantity: 3 }] };
const updateReq = createRequest(
`http://test/checkout_sessions/${session.id}`,
{ method: "POST", body },
);
const updateRes = await handlers.update(updateReq, session.id, body);

expect(updateRes.status).toBe(200);
const updated = await updateRes.json();
expect(updated.status).toBe("ready_for_payment");
expect(updated.totals.grand_total.amount).toBe(3000);
});
});

describe("Cancel flow", () => {
it("should cancel a session", async () => {
// Create session
Expand Down
Loading