From 23feaad95225208443fb22880ce68f1a67e3a296 Mon Sep 17 00:00:00 2001 From: airdropzamani Date: Wed, 2 Sep 2026 04:26:15 +0300 Subject: [PATCH 1/2] feat(api-utils): add HTTP 402 payment-required middleware Adds paymentRequiredValidator for signed ACK-Pay challenges, receipt verification via Authorization or X-ACK-Payment-Proof, and ackPayment context injection. Closes #171. Co-authored-by: Cursor --- tools/api-utils/src/exceptions.ts | 20 ++ .../payment-required-validator.test.ts | 199 ++++++++++++++++++ .../middleware/payment-required-validator.ts | 109 ++++++++++ 3 files changed, 328 insertions(+) create mode 100644 tools/api-utils/src/middleware/payment-required-validator.test.ts create mode 100644 tools/api-utils/src/middleware/payment-required-validator.ts diff --git a/tools/api-utils/src/exceptions.ts b/tools/api-utils/src/exceptions.ts index 8cdba94e..b6069608 100644 --- a/tools/api-utils/src/exceptions.ts +++ b/tools/api-utils/src/exceptions.ts @@ -1,3 +1,4 @@ +import type { createSignedPaymentRequest } from "@agentcommercekit/ack-pay" import { HTTPException } from "hono/http-exception" /** @@ -16,6 +17,12 @@ export function unauthorized(message = "Unauthorized"): never { }) } +export function forbidden(message = "Forbidden"): never { + throw new HTTPException(403, { + message, + }) +} + export function notFound(message = "Not Found"): never { throw new HTTPException(404, { message, @@ -27,3 +34,16 @@ export function internalServerError(message = "Internal Server Error"): never { message, }) } + +type PaymentChallenge = Awaited> + +export function paymentRequired(challenge: PaymentChallenge): never { + throw new HTTPException(402, { + res: new Response(JSON.stringify(challenge), { + status: 402, + headers: { + "Content-Type": "application/json", + }, + }), + }) +} diff --git a/tools/api-utils/src/middleware/payment-required-validator.test.ts b/tools/api-utils/src/middleware/payment-required-validator.test.ts new file mode 100644 index 00000000..52678bf6 --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.test.ts @@ -0,0 +1,199 @@ +import { createPaymentReceipt } from "@agentcommercekit/ack-pay" +import { + createDidKeyUri, + createDidPkhUri, + getDidResolver, + type DidUri, +} from "@agentcommercekit/did" +import { + createJwtSigner, + curveToJwtAlgorithm, + type JwtString, +} from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { signCredential } from "@agentcommercekit/vc" +import { Hono } from "hono" +import { beforeEach, describe, expect, it } from "vitest" + +import { errorHandler } from "./error-handler" +import { + paymentRequiredValidator, + type PaymentRequiredEnv, +} from "./payment-required-validator" + +describe("paymentRequiredValidator", () => { + let app: Hono + let paymentRequestIssuerDid: DidUri + let paymentRequestIssuerKeypair: Awaited> + let receiptIssuerKeypair: Awaited> + let receiptIssuerDid: DidUri + let signedReceiptJwt: JwtString + + beforeEach(async () => { + paymentRequestIssuerKeypair = await generateKeypair("secp256k1") + paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair) + receiptIssuerKeypair = await generateKeypair("secp256k1") + receiptIssuerDid = createDidKeyUri(receiptIssuerKeypair) + + const resolver = getDidResolver() + + app = new Hono() + app.onError(errorHandler) + app.get( + "/resource", + paymentRequiredValidator({ + resolver, + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + trustedReceiptIssuers: [receiptIssuerDid], + paymentRequest: { + id: crypto.randomUUID(), + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + }), + (c) => c.json({ ok: true }), + ) + + const paymentRequestToken = ( + await import("@agentcommercekit/ack-pay") + ).createSignedPaymentRequest( + { + id: "test-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const { paymentRequestToken: token } = await paymentRequestToken + + const unsignedReceipt = createPaymentReceipt({ + paymentRequestToken: token, + paymentOptionId: "test-option", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }) + + signedReceiptJwt = await signCredential(unsignedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + }) + + it("returns 402 with a signed payment challenge when no receipt is supplied", async () => { + const response = await app.request("/resource") + + expect(response.status).toBe(402) + + const body = await response.json() + expect(body.paymentRequestToken).toBeTypeOf("string") + expect(body.paymentRequest.paymentOptions).toHaveLength(1) + }) + + it("allows access when a valid receipt is supplied via Authorization", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${signedReceiptJwt}`, + }, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.ok).toBe(true) + }) + + it("allows access when a valid receipt is supplied via X-ACK-Payment-Proof", async () => { + const response = await app.request("/resource", { + headers: { + "X-ACK-Payment-Proof": signedReceiptJwt, + }, + }) + + expect(response.status).toBe(200) + }) + + it("returns 400 for a malformed receipt", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: "Bearer not-a-valid-jwt", + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 403 for a receipt from an untrusted issuer", async () => { + const untrustedKeypair = await generateKeypair("secp256k1") + const untrustedDid = createDidKeyUri(untrustedKeypair) + + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken } = await createSignedPaymentRequest( + { + id: "other-request", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const forgedReceipt = await signCredential( + createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "test-option", + issuer: untrustedDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }), + { + did: untrustedDid, + signer: createJwtSigner(untrustedKeypair), + }, + ) + + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${forgedReceipt}`, + }, + }) + + expect(response.status).toBe(403) + }) +}) diff --git a/tools/api-utils/src/middleware/payment-required-validator.ts b/tools/api-utils/src/middleware/payment-required-validator.ts new file mode 100644 index 00000000..9739e6e8 --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.ts @@ -0,0 +1,109 @@ +import { + createSignedPaymentRequest, + verifyPaymentReceipt, + type PaymentRequestInit, +} from "@agentcommercekit/ack-pay" +import type { DidUri, Resolvable } from "@agentcommercekit/did" +import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" +import { + CredentialVerificationError, + InvalidCredentialError, + UntrustedIssuerError, +} from "@agentcommercekit/vc" +import type { Context, MiddlewareHandler } from "hono" +import { HTTPException } from "hono/http-exception" + +import { badRequest, forbidden, paymentRequired } from "../exceptions" + +export type AckPayment = Awaited> + +export interface PaymentRequiredEnv { + Variables: { + ackPayment: AckPayment + } +} + +export interface PaymentRequiredOptions { + resolver: Resolvable + issuer: DidUri + signer: JwtSigner + algorithm: JwtAlgorithm + trustedReceiptIssuers?: string[] + /** + * Static payment request fields or a per-request resolver. + */ + paymentRequest: + | PaymentRequestInit + | ((c: Context) => PaymentRequestInit | Promise) +} + +const RECEIPT_HEADER = "x-ack-payment-proof" + +function extractReceipt(c: Context): string | undefined { + const proofHeader = c.req.header(RECEIPT_HEADER) + if (proofHeader) { + return proofHeader + } + + const authorization = c.req.header("Authorization") + if (authorization?.startsWith("Bearer ")) { + return authorization.slice("Bearer ".length).trim() + } + + return undefined +} + +/** + * ACK-Pay HTTP 402 middleware. Issues a signed payment challenge when no valid + * receipt is supplied; otherwise verifies the receipt and injects `ackPayment`. + */ +export const paymentRequiredValidator = ( + options: PaymentRequiredOptions, +): MiddlewareHandler => { + return async (c, next) => { + const receipt = extractReceipt(c) + + if (!receipt) { + const paymentRequestInit = + typeof options.paymentRequest === "function" + ? await options.paymentRequest(c) + : options.paymentRequest + + const challenge = await createSignedPaymentRequest(paymentRequestInit, { + issuer: options.issuer, + signer: options.signer, + algorithm: options.algorithm, + }) + + throw paymentRequired(challenge) + } + + try { + const ackPayment = await verifyPaymentReceipt(receipt, { + resolver: options.resolver, + trustedReceiptIssuers: options.trustedReceiptIssuers, + paymentRequestIssuer: options.issuer, + }) + + c.set("ackPayment", ackPayment) + await next() + } catch (error) { + if (error instanceof HTTPException) { + throw error + } + + if (error instanceof UntrustedIssuerError) { + forbidden("Untrusted receipt issuer") + } + + if ( + error instanceof InvalidCredentialError || + error instanceof CredentialVerificationError + ) { + badRequest("Invalid receipt") + } + + throw error + } + } +} From c9fc8180d8e1224b33cc436afda210412458d999 Mon Sep 17 00:00:00 2001 From: airdropzamani Date: Thu, 3 Sep 2026 04:43:29 +0300 Subject: [PATCH 2/2] fix(api-utils): harden payment-required middleware per review Reject blank proof headers with 400, bind verified receipts to the configured payment request id/options, and add regression coverage. Co-authored-by: Cursor --- .../payment-required-validator.test.ts | 467 ++++++++++-------- .../middleware/payment-required-validator.ts | 262 ++++++---- 2 files changed, 421 insertions(+), 308 deletions(-) diff --git a/tools/api-utils/src/middleware/payment-required-validator.test.ts b/tools/api-utils/src/middleware/payment-required-validator.test.ts index 52678bf6..cc0e53d3 100644 --- a/tools/api-utils/src/middleware/payment-required-validator.test.ts +++ b/tools/api-utils/src/middleware/payment-required-validator.test.ts @@ -1,199 +1,268 @@ -import { createPaymentReceipt } from "@agentcommercekit/ack-pay" -import { - createDidKeyUri, - createDidPkhUri, - getDidResolver, - type DidUri, -} from "@agentcommercekit/did" -import { - createJwtSigner, - curveToJwtAlgorithm, - type JwtString, -} from "@agentcommercekit/jwt" -import { generateKeypair } from "@agentcommercekit/keys" -import { signCredential } from "@agentcommercekit/vc" -import { Hono } from "hono" -import { beforeEach, describe, expect, it } from "vitest" - -import { errorHandler } from "./error-handler" -import { - paymentRequiredValidator, - type PaymentRequiredEnv, -} from "./payment-required-validator" - -describe("paymentRequiredValidator", () => { - let app: Hono - let paymentRequestIssuerDid: DidUri - let paymentRequestIssuerKeypair: Awaited> - let receiptIssuerKeypair: Awaited> - let receiptIssuerDid: DidUri - let signedReceiptJwt: JwtString - - beforeEach(async () => { - paymentRequestIssuerKeypair = await generateKeypair("secp256k1") - paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair) - receiptIssuerKeypair = await generateKeypair("secp256k1") - receiptIssuerDid = createDidKeyUri(receiptIssuerKeypair) - - const resolver = getDidResolver() - - app = new Hono() - app.onError(errorHandler) - app.get( - "/resource", - paymentRequiredValidator({ - resolver, - issuer: paymentRequestIssuerDid, - signer: createJwtSigner(paymentRequestIssuerKeypair), - algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), - trustedReceiptIssuers: [receiptIssuerDid], - paymentRequest: { - id: crypto.randomUUID(), - paymentOptions: [ - { - id: "test-option", - amount: 100, - decimals: 2, - currency: "USD", - recipient: paymentRequestIssuerDid, - }, - ], - }, - }), - (c) => c.json({ ok: true }), - ) - - const paymentRequestToken = ( - await import("@agentcommercekit/ack-pay") - ).createSignedPaymentRequest( - { - id: "test-request-id", - paymentOptions: [ - { - id: "test-option", - amount: 100, - decimals: 2, - currency: "USD", - recipient: paymentRequestIssuerDid, - }, - ], - }, - { - issuer: paymentRequestIssuerDid, - signer: createJwtSigner(paymentRequestIssuerKeypair), - algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), - }, - ) - - const { paymentRequestToken: token } = await paymentRequestToken - - const unsignedReceipt = createPaymentReceipt({ - paymentRequestToken: token, - paymentOptionId: "test-option", - issuer: receiptIssuerDid, - payerDid: createDidPkhUri( - "eip155:84532", - "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", - ), - }) - - signedReceiptJwt = await signCredential(unsignedReceipt, { - did: receiptIssuerDid, - signer: createJwtSigner(receiptIssuerKeypair), - }) - }) - - it("returns 402 with a signed payment challenge when no receipt is supplied", async () => { - const response = await app.request("/resource") - - expect(response.status).toBe(402) - - const body = await response.json() - expect(body.paymentRequestToken).toBeTypeOf("string") - expect(body.paymentRequest.paymentOptions).toHaveLength(1) - }) - - it("allows access when a valid receipt is supplied via Authorization", async () => { - const response = await app.request("/resource", { - headers: { - Authorization: `Bearer ${signedReceiptJwt}`, - }, - }) - - expect(response.status).toBe(200) - const body = await response.json() - expect(body.ok).toBe(true) - }) - - it("allows access when a valid receipt is supplied via X-ACK-Payment-Proof", async () => { - const response = await app.request("/resource", { - headers: { - "X-ACK-Payment-Proof": signedReceiptJwt, - }, - }) - - expect(response.status).toBe(200) - }) - - it("returns 400 for a malformed receipt", async () => { - const response = await app.request("/resource", { - headers: { - Authorization: "Bearer not-a-valid-jwt", - }, - }) - - expect(response.status).toBe(400) - }) - - it("returns 403 for a receipt from an untrusted issuer", async () => { - const untrustedKeypair = await generateKeypair("secp256k1") - const untrustedDid = createDidKeyUri(untrustedKeypair) - - const { createSignedPaymentRequest } = await import( - "@agentcommercekit/ack-pay" - ) - const { paymentRequestToken } = await createSignedPaymentRequest( - { - id: "other-request", - paymentOptions: [ - { - id: "test-option", - amount: 100, - decimals: 2, - currency: "USD", - recipient: paymentRequestIssuerDid, - }, - ], - }, - { - issuer: paymentRequestIssuerDid, - signer: createJwtSigner(paymentRequestIssuerKeypair), - algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), - }, - ) - - const forgedReceipt = await signCredential( - createPaymentReceipt({ - paymentRequestToken, - paymentOptionId: "test-option", - issuer: untrustedDid, - payerDid: createDidPkhUri( - "eip155:84532", - "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", - ), - }), - { - did: untrustedDid, - signer: createJwtSigner(untrustedKeypair), - }, - ) - - const response = await app.request("/resource", { - headers: { - Authorization: `Bearer ${forgedReceipt}`, - }, - }) - - expect(response.status).toBe(403) - }) -}) +import { createPaymentReceipt } from "@agentcommercekit/ack-pay" +import { + createDidKeyUri, + createDidPkhUri, + getDidResolver, + type DidUri, +} from "@agentcommercekit/did" +import { + createJwtSigner, + curveToJwtAlgorithm, + type JwtString, +} from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { signCredential } from "@agentcommercekit/vc" +import { Hono } from "hono" +import { beforeEach, describe, expect, it } from "vitest" + +import { errorHandler } from "./error-handler" +import { + paymentRequiredValidator, + type PaymentRequiredEnv, +} from "./payment-required-validator" + +const RESOURCE_PAYMENT_REQUEST = { + id: "test-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: "did:placeholder", + }, + ], +} + +describe("paymentRequiredValidator", () => { + let app: Hono + let paymentRequestIssuerDid: DidUri + let paymentRequestIssuerKeypair: Awaited> + let receiptIssuerKeypair: Awaited> + let receiptIssuerDid: DidUri + let signedReceiptJwt: JwtString + + beforeEach(async () => { + paymentRequestIssuerKeypair = await generateKeypair("secp256k1") + paymentRequestIssuerDid = createDidKeyUri(paymentRequestIssuerKeypair) + receiptIssuerKeypair = await generateKeypair("secp256k1") + receiptIssuerDid = createDidKeyUri(receiptIssuerKeypair) + + const resolver = getDidResolver() + const paymentRequest = { + ...RESOURCE_PAYMENT_REQUEST, + paymentOptions: [ + { + ...RESOURCE_PAYMENT_REQUEST.paymentOptions[0], + recipient: paymentRequestIssuerDid, + }, + ], + } + + app = new Hono() + app.onError(errorHandler) + app.get( + "/resource", + paymentRequiredValidator({ + resolver, + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + trustedReceiptIssuers: [receiptIssuerDid], + paymentRequest, + }), + (c) => c.json({ ok: true }), + ) + + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken: token } = await createSignedPaymentRequest( + paymentRequest, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const unsignedReceipt = createPaymentReceipt({ + paymentRequestToken: token, + paymentOptionId: "test-option", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }) + + signedReceiptJwt = await signCredential(unsignedReceipt, { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }) + }) + + it("returns 402 with a signed payment challenge when no receipt is supplied", async () => { + const response = await app.request("/resource") + + expect(response.status).toBe(402) + + const body = await response.json() + expect(body.paymentRequestToken).toBeTypeOf("string") + expect(body.paymentRequest.paymentOptions).toHaveLength(1) + }) + + it("returns 400 for a blank X-ACK-Payment-Proof header", async () => { + const response = await app.request("/resource", { + headers: { + "X-ACK-Payment-Proof": " ", + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 400 for a blank Authorization Bearer token", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: "Bearer ", + }, + }) + + expect(response.status).toBe(400) + }) + + it("allows access when a valid receipt is supplied via Authorization", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${signedReceiptJwt}`, + }, + }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.ok).toBe(true) + }) + + it("allows access when a valid receipt is supplied via X-ACK-Payment-Proof", async () => { + const response = await app.request("/resource", { + headers: { + "X-ACK-Payment-Proof": signedReceiptJwt, + }, + }) + + expect(response.status).toBe(200) + }) + + it("returns 400 for a malformed receipt", async () => { + const response = await app.request("/resource", { + headers: { + Authorization: "Bearer not-a-valid-jwt", + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 400 for a receipt bound to a different payment request", async () => { + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken } = await createSignedPaymentRequest( + { + id: "other-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const mismatchedReceipt = await signCredential( + createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "test-option", + issuer: receiptIssuerDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }), + { + did: receiptIssuerDid, + signer: createJwtSigner(receiptIssuerKeypair), + }, + ) + + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${mismatchedReceipt}`, + }, + }) + + expect(response.status).toBe(400) + }) + + it("returns 403 for a receipt from an untrusted issuer", async () => { + const untrustedKeypair = await generateKeypair("secp256k1") + const untrustedDid = createDidKeyUri(untrustedKeypair) + + const { createSignedPaymentRequest } = await import( + "@agentcommercekit/ack-pay" + ) + const { paymentRequestToken } = await createSignedPaymentRequest( + { + id: "test-request-id", + paymentOptions: [ + { + id: "test-option", + amount: 100, + decimals: 2, + currency: "USD", + recipient: paymentRequestIssuerDid, + }, + ], + }, + { + issuer: paymentRequestIssuerDid, + signer: createJwtSigner(paymentRequestIssuerKeypair), + algorithm: curveToJwtAlgorithm(paymentRequestIssuerKeypair.curve), + }, + ) + + const forgedReceipt = await signCredential( + createPaymentReceipt({ + paymentRequestToken, + paymentOptionId: "test-option", + issuer: untrustedDid, + payerDid: createDidPkhUri( + "eip155:84532", + "0x7B3D8F2E1C9A4B5D6E7F8A9B0C1D2E3F4A5B6C", + ), + }), + { + did: untrustedDid, + signer: createJwtSigner(untrustedKeypair), + }, + ) + + const response = await app.request("/resource", { + headers: { + Authorization: `Bearer ${forgedReceipt}`, + }, + }) + + expect(response.status).toBe(403) + }) +}) + \ No newline at end of file diff --git a/tools/api-utils/src/middleware/payment-required-validator.ts b/tools/api-utils/src/middleware/payment-required-validator.ts index 9739e6e8..3b95d0dd 100644 --- a/tools/api-utils/src/middleware/payment-required-validator.ts +++ b/tools/api-utils/src/middleware/payment-required-validator.ts @@ -1,109 +1,153 @@ -import { - createSignedPaymentRequest, - verifyPaymentReceipt, - type PaymentRequestInit, -} from "@agentcommercekit/ack-pay" -import type { DidUri, Resolvable } from "@agentcommercekit/did" -import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" -import { - CredentialVerificationError, - InvalidCredentialError, - UntrustedIssuerError, -} from "@agentcommercekit/vc" -import type { Context, MiddlewareHandler } from "hono" -import { HTTPException } from "hono/http-exception" - -import { badRequest, forbidden, paymentRequired } from "../exceptions" - -export type AckPayment = Awaited> - -export interface PaymentRequiredEnv { - Variables: { - ackPayment: AckPayment - } -} - -export interface PaymentRequiredOptions { - resolver: Resolvable - issuer: DidUri - signer: JwtSigner - algorithm: JwtAlgorithm - trustedReceiptIssuers?: string[] - /** - * Static payment request fields or a per-request resolver. - */ - paymentRequest: - | PaymentRequestInit - | ((c: Context) => PaymentRequestInit | Promise) -} - -const RECEIPT_HEADER = "x-ack-payment-proof" - -function extractReceipt(c: Context): string | undefined { - const proofHeader = c.req.header(RECEIPT_HEADER) - if (proofHeader) { - return proofHeader - } - - const authorization = c.req.header("Authorization") - if (authorization?.startsWith("Bearer ")) { - return authorization.slice("Bearer ".length).trim() - } - - return undefined -} - -/** - * ACK-Pay HTTP 402 middleware. Issues a signed payment challenge when no valid - * receipt is supplied; otherwise verifies the receipt and injects `ackPayment`. - */ -export const paymentRequiredValidator = ( - options: PaymentRequiredOptions, -): MiddlewareHandler => { - return async (c, next) => { - const receipt = extractReceipt(c) - - if (!receipt) { - const paymentRequestInit = - typeof options.paymentRequest === "function" - ? await options.paymentRequest(c) - : options.paymentRequest - - const challenge = await createSignedPaymentRequest(paymentRequestInit, { - issuer: options.issuer, - signer: options.signer, - algorithm: options.algorithm, - }) - - throw paymentRequired(challenge) - } - - try { - const ackPayment = await verifyPaymentReceipt(receipt, { - resolver: options.resolver, - trustedReceiptIssuers: options.trustedReceiptIssuers, - paymentRequestIssuer: options.issuer, - }) - - c.set("ackPayment", ackPayment) - await next() - } catch (error) { - if (error instanceof HTTPException) { - throw error - } - - if (error instanceof UntrustedIssuerError) { - forbidden("Untrusted receipt issuer") - } - - if ( - error instanceof InvalidCredentialError || - error instanceof CredentialVerificationError - ) { - badRequest("Invalid receipt") - } - - throw error - } - } -} +import { + createSignedPaymentRequest, + verifyPaymentReceipt, + type PaymentRequest, + type PaymentRequestInit, +} from "@agentcommercekit/ack-pay" +import type { DidUri, Resolvable } from "@agentcommercekit/did" +import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" +import { + CredentialVerificationError, + InvalidCredentialError, + UntrustedIssuerError, +} from "@agentcommercekit/vc" +import type { Context, MiddlewareHandler } from "hono" +import { HTTPException } from "hono/http-exception" + +import { badRequest, forbidden, paymentRequired } from "../exceptions" + +export type AckPayment = Awaited> + +export interface PaymentRequiredEnv { + Variables: { + ackPayment: AckPayment + } +} + +export interface PaymentRequiredOptions { + resolver: Resolvable + issuer: DidUri + signer: JwtSigner + algorithm: JwtAlgorithm + trustedReceiptIssuers?: string[] + /** + * Static payment request fields or a per-request resolver. + */ + paymentRequest: + | PaymentRequestInit + | ((c: Context) => PaymentRequestInit | Promise) +} + +const RECEIPT_HEADER = "x-ack-payment-proof" + +/** + * Extract a payment receipt JWT from request headers. + * Distinguishes "header absent" from "header present but blank". + */ +function extractReceipt(c: Context): + | { kind: "absent" } + | { kind: "blank" } + | { kind: "present"; receipt: string } { + const proofHeader = c.req.header(RECEIPT_HEADER) + if (proofHeader !== undefined) { + const trimmed = proofHeader.trim() + return trimmed + ? { kind: "present", receipt: trimmed } + : { kind: "blank" } + } + + const authorization = c.req.header("Authorization") + if (authorization !== undefined) { + if (!authorization.startsWith("Bearer ")) { + return { kind: "blank" } + } + const trimmed = authorization.slice("Bearer ".length).trim() + return trimmed + ? { kind: "present", receipt: trimmed } + : { kind: "blank" } + } + + return { kind: "absent" } +} + +function receiptMatchesRequest( + paymentRequest: PaymentRequest | null, + expected: PaymentRequestInit, +): boolean { + if (!paymentRequest) { + return false + } + if (paymentRequest.id !== expected.id) { + return false + } + const expectedOptionIds = new Set( + expected.paymentOptions.map((option) => option.id), + ) + return paymentRequest.paymentOptions.some((option) => + expectedOptionIds.has(option.id), + ) +} + +/** + * ACK-Pay HTTP 402 middleware. Issues a signed payment challenge when no valid + * receipt is supplied; otherwise verifies the receipt and injects `ackPayment`. + */ +export const paymentRequiredValidator = ( + options: PaymentRequiredOptions, +): MiddlewareHandler => { + return async (c, next) => { + const extracted = extractReceipt(c) + + if (extracted.kind === "blank") { + badRequest("Invalid receipt") + } + + const paymentRequestInit = + typeof options.paymentRequest === "function" + ? await options.paymentRequest(c) + : options.paymentRequest + + if (extracted.kind === "absent") { + const challenge = await createSignedPaymentRequest(paymentRequestInit, { + issuer: options.issuer, + signer: options.signer, + algorithm: options.algorithm, + }) + + throw paymentRequired(challenge) + } + + try { + const ackPayment = await verifyPaymentReceipt(extracted.receipt, { + resolver: options.resolver, + trustedReceiptIssuers: options.trustedReceiptIssuers, + paymentRequestIssuer: options.issuer, + }) + + if (!receiptMatchesRequest(ackPayment.paymentRequest, paymentRequestInit)) { + badRequest("Invalid receipt") + } + + c.set("ackPayment", ackPayment) + await next() + } catch (error) { + if (error instanceof HTTPException) { + throw error + } + + if (error instanceof UntrustedIssuerError) { + forbidden("Untrusted receipt issuer") + } + + if ( + error instanceof InvalidCredentialError || + error instanceof CredentialVerificationError + ) { + badRequest("Invalid receipt") + } + + throw error + } + } +} + \ No newline at end of file