From fd7063d2d9e804e4d62af4553f37e19dc74d3c1e Mon Sep 17 00:00:00 2001 From: vhtgzzl <317591381+vhtgzzl@users.noreply.github.com> Date: Sat, 22 Aug 2026 16:28:56 +0300 Subject: [PATCH] feat(api-utils): add paymentRequiredValidator middleware for HTTP 402 ACK-Pay challenges --- tools/api-utils/src/exceptions.ts | 10 + .../payment-required-validator.test.ts | 185 ++++++++++++++++++ .../middleware/payment-required-validator.ts | 149 ++++++++++++++ 3 files changed, 344 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 8cdba94..4aba8b2 100644 --- a/tools/api-utils/src/exceptions.ts +++ b/tools/api-utils/src/exceptions.ts @@ -16,6 +16,16 @@ export function unauthorized(message = "Unauthorized"): never { }) } +export function paymentRequired( + response?: Response, + message = "Payment Required", +): never { + throw new HTTPException(402, { + res: response, + message, + }) +} + export function notFound(message = "Not Found"): never { throw new HTTPException(404, { message, 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 0000000..87ce74e --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.test.ts @@ -0,0 +1,185 @@ +import { DidResolver } from "@agentcommercekit/did" +import { createJwtSigner } from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import * as ackPay from "@agentcommercekit/ack-pay" +import { Hono } from "hono" +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { + paymentRequiredValidator, + type PaymentRequiredEnv, +} from "./payment-required-validator" + +describe("paymentRequiredValidator", () => { + const mockPaymentRequestInit: ackPay.PaymentRequestInit = { + id: "test_req_001", + description: "API access fee", + paymentOptions: [ + { + id: "usdc-opt-1", + amount: 50000, + decimals: 6, + currency: "USDC", + recipient: "0x1234567890abcdef1234567890abcdef12345678", + }, + ], + } + + let serverKeypair: any + let serverSigner: any + let resolver: DidResolver + + beforeEach(async () => { + serverKeypair = await generateKeypair("secp256k1") + serverSigner = createJwtSigner(serverKeypair) + resolver = new DidResolver() + }) + + it("returns HTTP 402 with signed payment request when no receipt is present", async () => { + const app = new Hono() + app.use("*", async (c, next) => { + c.set("resolver", resolver) + await next() + }) + + app.get( + "/protected", + paymentRequiredValidator({ + paymentRequest: mockPaymentRequestInit, + signerOptions: { + issuer: "did:web:server.catena.com", + signer: serverSigner, + }, + }), + (c) => c.json({ access: "granted" }), + ) + + const res = await app.request("/protected") + expect(res.status).toBe(402) + + const data = await res.json() + expect(data.paymentRequest).toBeDefined() + expect(data.paymentRequest.id).toBe("test_req_001") + expect(data.paymentRequestToken).toBeDefined() + expect(typeof data.paymentRequestToken).toBe("string") + }) + + it("verifies valid receipt in Authorization header and allows access", async () => { + const mockVerifiedPayment = { + receipt: { id: "receipt_vc_001" }, + paymentRequestToken: "mock.jwt.token", + paymentRequest: mockPaymentRequestInit as any, + } + + vi.spyOn(ackPay, "verifyPaymentReceipt").mockResolvedValue( + mockVerifiedPayment as any, + ) + + const app = new Hono() + app.use("*", async (c, next) => { + c.set("resolver", resolver) + await next() + }) + + app.get( + "/protected", + paymentRequiredValidator({ + paymentRequest: mockPaymentRequestInit, + signerOptions: { + issuer: "did:web:server.catena.com", + signer: serverSigner, + }, + trustedReceiptIssuers: ["did:web:receipt.catena.com"], + }), + (c) => { + const payment = c.get("ackPayment") + return c.json({ access: "granted", payment }) + }, + ) + + const res = await app.request("/protected", { + headers: { + Authorization: "Bearer mock.valid.jwt.receipt", + }, + }) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.access).toBe("granted") + expect(data.payment).toEqual(mockVerifiedPayment) + }) + + it("accepts payment receipt from X-ACK-Payment-Proof header", async () => { + const mockVerifiedPayment = { + receipt: { id: "receipt_vc_002" }, + paymentRequestToken: "mock.jwt.token", + paymentRequest: mockPaymentRequestInit as any, + } + + vi.spyOn(ackPay, "verifyPaymentReceipt").mockResolvedValue( + mockVerifiedPayment as any, + ) + + const app = new Hono() + app.use("*", async (c, next) => { + c.set("resolver", resolver) + await next() + }) + + app.get( + "/protected", + paymentRequiredValidator({ + paymentRequest: mockPaymentRequestInit, + signerOptions: { + issuer: "did:web:server.catena.com", + signer: serverSigner, + }, + }), + (c) => c.json({ access: "granted", payment: c.get("ackPayment") }), + ) + + const res = await app.request("/protected", { + headers: { + "X-ACK-Payment-Proof": "mock.proof.header.receipt", + }, + }) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.access).toBe("granted") + }) + + it("returns HTTP 400 when invalid receipt is provided", async () => { + vi.spyOn(ackPay, "verifyPaymentReceipt").mockRejectedValue( + new Error("Invalid cryptographic receipt signature"), + ) + + const app = new Hono() + app.use("*", async (c, next) => { + c.set("resolver", resolver) + await next() + }) + + app.get( + "/protected", + paymentRequiredValidator({ + paymentRequest: mockPaymentRequestInit, + signerOptions: { + issuer: "did:web:server.catena.com", + signer: serverSigner, + }, + }), + (c) => c.json({ access: "granted" }), + ) + + const res = await app.request("/protected", { + headers: { + Authorization: "Bearer invalid.receipt.token", + }, + }) + + expect(res.status).toBe(400) + const data = await res.json() + expect(data.message).toBe("Invalid receipt") + }) +}) 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 0000000..fad1961 --- /dev/null +++ b/tools/api-utils/src/middleware/payment-required-validator.ts @@ -0,0 +1,149 @@ +import type { Resolvable } from "@agentcommercekit/did" +import type { JwtAlgorithm, JwtSigner } from "@agentcommercekit/jwt" +import { + createSignedPaymentRequest, + verifyPaymentReceipt, + type PaymentRequest, + type PaymentRequestInit, +} from "@agentcommercekit/ack-pay" +import type { Context, MiddlewareHandler } from "hono" +import { HTTPException } from "hono/http-exception" + +export interface PaymentRequiredEnv { + Variables: { + resolver?: Resolvable + ackPayment: { + receipt: unknown + paymentRequestToken: string + paymentRequest: PaymentRequest | null + } + } +} + +export interface PaymentRequestSignerOptions { + issuer: string + signer: JwtSigner + algorithm?: JwtAlgorithm +} + +export interface PaymentRequiredValidatorOptions { + /** + * The payment request configuration or a dynamic resolver function + */ + paymentRequest: + | PaymentRequestInit + | ((c: Context) => Promise | PaymentRequestInit) + + /** + * The signer configuration for signing the payment request token JWT + */ + signerOptions: + | PaymentRequestSignerOptions + | ((c: Context) => Promise | PaymentRequestSignerOptions) + + /** + * The list of trusted receipt issuer DIDs + */ + trustedReceiptIssuers?: + | string[] + | ((c: Context) => Promise | string[]) + + /** + * The expected issuer of the original payment request token + */ + paymentRequestIssuer?: string + + /** + * Whether to verify the payment request token as a JWT (defaults to true) + */ + verifyPaymentRequestTokenJwt?: boolean +} + +/** + * Middleware that enforces an ACK-Pay HTTP 402 challenge. + * + * If no receipt is present in the `Authorization: Bearer ` or + * `X-ACK-Payment-Proof` headers, it automatically issues an HTTP 402 status code + * and returns the signed ACK-Pay payment request body. + * + * When a receipt is present, it verifies the receipt against trusted issuers and + * attaches the verified payment details to `c.get("ackPayment")`. + * + * @example + * ```ts + * app.get( + * "/resource", + * paymentRequiredValidator({ + * paymentRequest: paymentRequestConfig, + * signerOptions: serverSignerConfig, + * trustedReceiptIssuers: ["did:web:receipt.catena.com"], + * }), + * (c) => { + * const payment = c.get("ackPayment") + * return c.json({ access: "granted", payment }) + * } + * ) + * ``` + */ +export const paymentRequiredValidator = ( + options: PaymentRequiredValidatorOptions, +): MiddlewareHandler => { + return async (c, next) => { + const authorizationHeader = c.req.header("Authorization") + const proofHeader = c.req.header("X-ACK-Payment-Proof") + + const receipt = authorizationHeader?.startsWith("Bearer ") + ? authorizationHeader.replace("Bearer ", "").trim() + : proofHeader?.trim() + + if (!receipt) { + const init = + typeof options.paymentRequest === "function" + ? await options.paymentRequest(c) + : options.paymentRequest + + const signer = + typeof options.signerOptions === "function" + ? await options.signerOptions(c) + : options.signerOptions + + const signedPaymentRequest = await createSignedPaymentRequest( + init, + signer, + ) + + const res = new Response(JSON.stringify(signedPaymentRequest), { + status: 402, + headers: { + "Content-Type": "application/json", + }, + }) + + throw new HTTPException(402, { res }) + } + + const didResolver = c.get("resolver") + const trustedReceiptIssuers = + typeof options.trustedReceiptIssuers === "function" + ? await options.trustedReceiptIssuers(c) + : options.trustedReceiptIssuers + + try { + const verified = await verifyPaymentReceipt(receipt, { + resolver: didResolver!, + trustedReceiptIssuers, + paymentRequestIssuer: options.paymentRequestIssuer, + verifyPaymentRequestTokenJwt: + options.verifyPaymentRequestTokenJwt ?? true, + }) + + c.set("ackPayment", verified) + } catch (_e) { + throw new HTTPException(400, { + message: "Invalid receipt", + }) + } + + await next() + } +}