diff --git a/demos/payments/README.md b/demos/payments/README.md index 65bbfa5..b59d901 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -20,9 +20,30 @@ This interactive command-line demo showcases a common use case: the **Server-Ini - Handling currency conversions. - Integrating compliance checks (KYC/AML). - Facilitating complex payment routing. + - Enforcing local payment policy before returning an execution URL or signing a receipt-service payload. You can learn more about the full ACK-Pay protocol at [www.agentcommercekit.com](https://www.agentcommercekit.com). +## Policy-before-signing example + +The Stripe Payment Service path includes a tiny local policy guard in +`src/payment-policy.ts`. The guard runs after the Payment Request token and +payment option are verified, but before the demo returns a payment URL or signs +the payload that asks the Receipt Service to issue a receipt. + +This is an example pattern, not a normative ACK-Pay policy engine. Production +Payment Services should replace it with their own owner, risk, compliance, or +human-approval system. The important safety boundary is that policy enforcement +happens before execution or signing: + +- known low-value recipient: continue automatically +- unknown recipient: return `approval_required` +- amount above the autonomous spend limit: deny before payment execution + +The demo allowlist is based on the configured server identity, not the issuer +claimed by each incoming Payment Request token. A real Payment Service should +load this allowlist from operator-controlled configuration. + ## Demo Video https://github.com/user-attachments/assets/193b8f66-443f-457f-9370-32835f3b1c85 diff --git a/demos/payments/src/payment-policy.test.ts b/demos/payments/src/payment-policy.test.ts new file mode 100644 index 0000000..3590d5b --- /dev/null +++ b/demos/payments/src/payment-policy.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from "vitest" + +import { evaluatePaymentPolicy } from "./payment-policy" + +const basePaymentOption = { + id: "base-usdc", + amount: 100, + decimals: 2, + currency: "USDC", + recipient: "did:example:merchant", +} + +describe("evaluatePaymentPolicy", () => { + it("approves below-threshold payments to allowed recipients", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: 1_000, + }) + + expect(decision).toEqual({ + status: "approved", + }) + }) + + it("does not approve self-asserted recipients without an allowlist", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: [], + maxAutonomousAmount: 1_000, + }) + + expect(decision).toEqual({ + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + }) + }) + + it("requires approval before execution for unknown recipients", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: ["did:example:trusted-merchant"], + maxAutonomousAmount: 1_000, + }) + + expect(decision).toEqual({ + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + }) + }) + + it("denies payments above the autonomous spend limit", () => { + const decision = evaluatePaymentPolicy( + { + ...basePaymentOption, + amount: 10_000, + }, + { + allowedRecipients: [basePaymentOption.recipient], + maxAutonomousAmount: 1_000, + }, + ) + + expect(decision).toEqual({ + status: "denied", + reason: "Payment amount exceeds the autonomous spend limit", + }) + }) +}) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts new file mode 100644 index 0000000..4d15b4a --- /dev/null +++ b/demos/payments/src/payment-policy.ts @@ -0,0 +1,63 @@ +import type { PaymentOption } from "agentcommercekit" + +export type PaymentPolicyDecision = + | { + status: "approved" + } + | { + status: "approval_required" | "denied" + reason: string + } + +export interface PaymentPolicy { + allowedRecipients: readonly string[] + maxAutonomousAmount: number +} + +export const demoPaymentPolicy: PaymentPolicy = { + allowedRecipients: [], + maxAutonomousAmount: 1_000_000, +} + +export function evaluatePaymentPolicy( + paymentOption: PaymentOption, + policy: PaymentPolicy = demoPaymentPolicy, +): PaymentPolicyDecision { + const amount = Number(paymentOption.amount) + + if (!Number.isFinite(amount)) { + return { + status: "denied", + reason: "Payment amount must be a finite number", + } + } + + if (amount > policy.maxAutonomousAmount) { + return { + status: "denied", + reason: "Payment amount exceeds the autonomous spend limit", + } + } + + if (!policy.allowedRecipients.includes(paymentOption.recipient)) { + return { + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", + } + } + + return { + status: "approved", + } +} + +export function assertPaymentPolicyApproved( + paymentOption: PaymentOption, + policy: PaymentPolicy = demoPaymentPolicy, +) { + const decision = evaluatePaymentPolicy(paymentOption, policy) + + if (decision.status !== "approved") { + throw new Error(decision.reason) + } +} diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 356ba5f..3baef9a 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -10,12 +10,13 @@ import { type Verifiable, } from "agentcommercekit" import { jwtStringSchema } from "agentcommercekit/schemas/valibot" -import { Hono, type Env, type TypedResponse } from "hono" +import { Hono, type Context, type Env, type TypedResponse } from "hono" import { env } from "hono/adapter" import { HTTPException } from "hono/http-exception" import * as v from "valibot" import { PAYMENT_SERVICE_URL } from "./constants" +import { evaluatePaymentPolicy } from "./payment-policy" import { getKeypairInfo } from "./utils/keypair-info" const app = new Hono() @@ -39,8 +40,13 @@ app.post("/", async (c): Promise> => { await c.req.json(), ) - // Verify the payment request token and payment option are valid - await validatePaymentOption(paymentOptionId, paymentRequestToken) + // Verify the payment request token and payment option are valid before + // returning an execution URL. + const { paymentOption } = await validatePaymentOption( + paymentOptionId, + paymentRequestToken, + ) + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -81,6 +87,7 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) const payload = { paymentRequestToken, @@ -124,7 +131,7 @@ async function validatePaymentOption( const didResolver = getDidResolver() log(colors.dim(`${name} Verifying payment request token...`)) - const { paymentRequest } = await verifyPaymentRequestToken( + const { paymentRequest, parsed } = await verifyPaymentRequestToken( paymentRequestToken, { resolver: didResolver, @@ -146,9 +153,34 @@ async function validatePaymentOption( return { paymentRequest, paymentOption, + parsed, } } +function enforcePaymentPolicy( + paymentOption: Awaited< + ReturnType + >["paymentOption"], + allowedRecipients: readonly string[], +) { + const decision = evaluatePaymentPolicy(paymentOption, { + allowedRecipients, + maxAutonomousAmount: 1_000_000, + }) + + if (decision.status !== "approved") { + log(errorMessage(`${name} ${decision.reason}`)) + throw new HTTPException(decision.status === "denied" ? 403 : 409, { + message: decision.reason, + }) + } +} + +async function getTrustedRecipients(c: Context) { + const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX) + return [serverIdentity.did] +} + serve({ port: 4569, fetch: app.fetch,