From 264bbd2bf0ea1abc4d26c3cbb8b436b1ae1c8356 Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Fri, 15 May 2026 21:49:54 +0300 Subject: [PATCH 1/2] demo(payments): enforce policy before signing --- demos/payments/README.md | 17 ++++++ demos/payments/src/payment-policy.test.ts | 66 ++++++++++++++++++++++ demos/payments/src/payment-policy.ts | 67 +++++++++++++++++++++++ demos/payments/src/payment-service.ts | 36 ++++++++++-- 4 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 demos/payments/src/payment-policy.test.ts create mode 100644 demos/payments/src/payment-policy.ts diff --git a/demos/payments/README.md b/demos/payments/README.md index 65bbfa5..9d14cdb 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -20,9 +20,26 @@ 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 + ## 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..c61adce --- /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("approves payments back to the trusted request issuer", () => { + const decision = evaluatePaymentPolicy(basePaymentOption, { + allowedRecipients: [], + maxAutonomousAmount: 1_000, + trustedRequestIssuer: basePaymentOption.recipient, + }) + + expect(decision).toEqual({ + status: "approved", + }) + }) + + 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..9c6b3ca --- /dev/null +++ b/demos/payments/src/payment-policy.ts @@ -0,0 +1,67 @@ +import type { PaymentOption } from "agentcommercekit" + +export type PaymentPolicyDecision = + | { + status: "approved" + } + | { + status: "approval_required" | "denied" + reason: string + } + +export interface PaymentPolicy { + allowedRecipients: readonly string[] + maxAutonomousAmount: number + trustedRequestIssuer?: string +} + +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) && + paymentOption.recipient !== policy.trustedRequestIssuer + ) { + 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..0b377e6 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -16,6 +16,7 @@ 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, parsed } = await validatePaymentOption( + paymentOptionId, + paymentRequestToken, + ) + enforcePaymentPolicy(paymentOption, parsed.issuer) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -73,7 +79,7 @@ app.post( ) // Verify the payment request token and payment option are valid - const { paymentOption } = await validatePaymentOption( + const { paymentOption, parsed } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) @@ -81,6 +87,7 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } + enforcePaymentPolicy(paymentOption, parsed.issuer) 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,6 +153,27 @@ async function validatePaymentOption( return { paymentRequest, paymentOption, + parsed, + } +} + +function enforcePaymentPolicy( + paymentOption: Awaited< + ReturnType + >["paymentOption"], + trustedRequestIssuer?: string, +) { + const decision = evaluatePaymentPolicy(paymentOption, { + allowedRecipients: [], + maxAutonomousAmount: 1_000_000, + trustedRequestIssuer, + }) + + if (decision.status !== "approved") { + log(errorMessage(`${name} ${decision.reason}`)) + throw new HTTPException(decision.status === "denied" ? 403 : 409, { + message: decision.reason, + }) } } From f0f5df853c7b2bd8565cd1a7bdd6784b7f04706a Mon Sep 17 00:00:00 2001 From: EfeDurmaz16 Date: Sat, 16 May 2026 07:56:44 +0300 Subject: [PATCH 2/2] fix(demo): use configured payment recipient allowlist Signed-off-by: EfeDurmaz16 --- demos/payments/README.md | 4 ++++ demos/payments/src/payment-policy.test.ts | 6 +++--- demos/payments/src/payment-policy.ts | 6 +----- demos/payments/src/payment-service.ts | 20 ++++++++++++-------- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/demos/payments/README.md b/demos/payments/README.md index 9d14cdb..b59d901 100644 --- a/demos/payments/README.md +++ b/demos/payments/README.md @@ -40,6 +40,10 @@ happens before execution or signing: - 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 index c61adce..3590d5b 100644 --- a/demos/payments/src/payment-policy.test.ts +++ b/demos/payments/src/payment-policy.test.ts @@ -22,15 +22,15 @@ describe("evaluatePaymentPolicy", () => { }) }) - it("approves payments back to the trusted request issuer", () => { + it("does not approve self-asserted recipients without an allowlist", () => { const decision = evaluatePaymentPolicy(basePaymentOption, { allowedRecipients: [], maxAutonomousAmount: 1_000, - trustedRequestIssuer: basePaymentOption.recipient, }) expect(decision).toEqual({ - status: "approved", + status: "approval_required", + reason: "Recipient is not on the autonomous payment allowlist", }) }) diff --git a/demos/payments/src/payment-policy.ts b/demos/payments/src/payment-policy.ts index 9c6b3ca..4d15b4a 100644 --- a/demos/payments/src/payment-policy.ts +++ b/demos/payments/src/payment-policy.ts @@ -12,7 +12,6 @@ export type PaymentPolicyDecision = export interface PaymentPolicy { allowedRecipients: readonly string[] maxAutonomousAmount: number - trustedRequestIssuer?: string } export const demoPaymentPolicy: PaymentPolicy = { @@ -40,10 +39,7 @@ export function evaluatePaymentPolicy( } } - if ( - !policy.allowedRecipients.includes(paymentOption.recipient) && - paymentOption.recipient !== policy.trustedRequestIssuer - ) { + if (!policy.allowedRecipients.includes(paymentOption.recipient)) { return { status: "approval_required", reason: "Recipient is not on the autonomous payment allowlist", diff --git a/demos/payments/src/payment-service.ts b/demos/payments/src/payment-service.ts index 0b377e6..3baef9a 100644 --- a/demos/payments/src/payment-service.ts +++ b/demos/payments/src/payment-service.ts @@ -10,7 +10,7 @@ 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" @@ -42,11 +42,11 @@ app.post("/", async (c): Promise> => { // Verify the payment request token and payment option are valid before // returning an execution URL. - const { paymentOption, parsed } = await validatePaymentOption( + const { paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) - enforcePaymentPolicy(paymentOption, parsed.issuer) + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) log(colors.dim(`${name} Generating Stripe payment URL ...`)) @@ -79,7 +79,7 @@ app.post( ) // Verify the payment request token and payment option are valid - const { paymentOption, parsed } = await validatePaymentOption( + const { paymentOption } = await validatePaymentOption( paymentOptionId, paymentRequestToken, ) @@ -87,7 +87,7 @@ app.post( if (!receiptServiceUrl) { throw new Error(errorMessage("Receipt service URL is required")) } - enforcePaymentPolicy(paymentOption, parsed.issuer) + enforcePaymentPolicy(paymentOption, await getTrustedRecipients(c)) const payload = { paymentRequestToken, @@ -161,12 +161,11 @@ function enforcePaymentPolicy( paymentOption: Awaited< ReturnType >["paymentOption"], - trustedRequestIssuer?: string, + allowedRecipients: readonly string[], ) { const decision = evaluatePaymentPolicy(paymentOption, { - allowedRecipients: [], + allowedRecipients, maxAutonomousAmount: 1_000_000, - trustedRequestIssuer, }) if (decision.status !== "approved") { @@ -177,6 +176,11 @@ function enforcePaymentPolicy( } } +async function getTrustedRecipients(c: Context) { + const serverIdentity = await getKeypairInfo(env(c).SERVER_PRIVATE_KEY_HEX) + return [serverIdentity.did] +} + serve({ port: 4569, fetch: app.fetch,