From 8af018d232d68fe6e9e66b1d5112cfaa10bc7965 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 10:46:54 -0400 Subject: [PATCH 1/9] feat(ap2): add v0.2 mandate build/sign/verify support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the AP2 v0.2 mandate model: Open/Closed Checkout Mandate and Open/Closed Payment Mandate, as SD-JWTs with a delegate_payload envelope (RFC 9901 array-element disclosure wrapping the mandate content, not a flat payload). Mandates are self-signed (BYOK signer, same contract as signReceipt) rather than issued by a third party. Adds: - buildOpen/ClosedCheckoutMandate, buildOpen/ClosedPaymentMandate - signOpen/ClosedCheckoutMandate, signOpen/ClosedPaymentMandate - verifyClosedCheckoutMandate, verifyClosedPaymentMandate — brought in-house per this package now owning mandate verification, rather than requiring an external dependency - computeCheckoutHash / computeDisclosureDigest — the two distinct hash primitives mandates need (hash of checkout_jwt vs. per-disclosure digest), reusing existing crypto-utils SD-JWT primitives throughout Co-Authored-By: Claude Sonnet 5 --- packages/ap2/src/index.js | 2 + packages/ap2/src/mandates.js | 587 ++++++++++++++++++ .../src/schemas/checkout-mandate-closed.json | 27 + .../src/schemas/checkout-mandate-open.json | 49 ++ .../src/schemas/payment-mandate-closed.json | 52 ++ .../ap2/src/schemas/payment-mandate-open.json | 47 ++ packages/ap2/src/utils.js | 39 ++ 7 files changed, 803 insertions(+) create mode 100644 packages/ap2/src/mandates.js create mode 100644 packages/ap2/src/schemas/checkout-mandate-closed.json create mode 100644 packages/ap2/src/schemas/checkout-mandate-open.json create mode 100644 packages/ap2/src/schemas/payment-mandate-closed.json create mode 100644 packages/ap2/src/schemas/payment-mandate-open.json diff --git a/packages/ap2/src/index.js b/packages/ap2/src/index.js index 145fa00d3..3b1efcfba 100644 --- a/packages/ap2/src/index.js +++ b/packages/ap2/src/index.js @@ -1,8 +1,10 @@ export * from './receipts'; +export * from './mandates'; export { computeSdHash } from '@docknetwork/crypto-utils/vc'; export { encodeDisclosure, inferReceiptType, validateReceipt, validateReceiptTime, + validateMandateContent, } from './utils'; diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js new file mode 100644 index 000000000..d4af03486 --- /dev/null +++ b/packages/ap2/src/mandates.js @@ -0,0 +1,587 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { + signJWT, + verifyJWT, + encodeSdJwtDisclosure, + parseSdJwtPresentation, + computeSdHash, + jwkToSecp256r1PublicKey, +} from '@docknetwork/crypto-utils/vc'; +import { + MANDATE_TYPE_CHECKOUT_OPEN, + MANDATE_TYPE_CHECKOUT_CLOSED, + MANDATE_TYPE_PAYMENT_OPEN, + MANDATE_TYPE_PAYMENT_CLOSED, + DEFAULT_CLOCK_TOLERANCE, + validateMandateContent, +} from './utils'; + +export { + MANDATE_TYPE_CHECKOUT_OPEN, + MANDATE_TYPE_CHECKOUT_CLOSED, + MANDATE_TYPE_PAYMENT_OPEN, + MANDATE_TYPE_PAYMENT_CLOSED, +} from './utils'; + +const DEFAULT_SD_ALG = 'sha-256'; +const SD_HASH_ALGORITHMS = { + 'sha-256': 'sha256', + 'sha-384': 'sha384', + 'sha-512': 'sha512', +}; + +function resolveHashAlgorithm(sdAlg) { + const hashAlgorithm = SD_HASH_ALGORITHMS[sdAlg]; + if (!hashAlgorithm) { + throw new Error(`Unsupported SD-JWT hash algorithm: ${sdAlg}`); + } + return hashAlgorithm; +} + +function randomSalt() { + return randomBytes(16).toString('base64url'); +} + +/** + * Computes the RFC 9901 digest of a single encoded disclosure. This is the + * primitive behind every `_sd` array entry and every `{"...": digest}` + * array-element placeholder — distinct from `computeSdHash`, which hashes an + * entire issuer-signed JWT plus a set of disclosures (a presentation), not a + * single disclosure. + * + * @param {string} encodedDisclosure + * @param {string} sdAlg + * @returns {string} + */ +export function computeDisclosureDigest(encodedDisclosure, sdAlg = DEFAULT_SD_ALG) { + const hashAlgorithm = resolveHashAlgorithm(sdAlg); + return createHash(hashAlgorithm) + .update(Buffer.from(encodedDisclosure, 'ascii')) + .digest('base64url'); +} + +/** + * Computes checkout_hash / transaction_id: the base64url hash of the raw + * checkout_jwt string value (not of a disclosure wrapping it). + * + * @param {string} checkoutJwt + * @param {string} sdAlg + * @returns {string} + */ +export function computeCheckoutHash(checkoutJwt, sdAlg = DEFAULT_SD_ALG) { + if (typeof checkoutJwt !== 'string' || checkoutJwt.length === 0) { + throw new TypeError('"checkoutJwt" must be a non-empty string'); + } + const hashAlgorithm = resolveHashAlgorithm(sdAlg); + return createHash(hashAlgorithm) + .update(Buffer.from(checkoutJwt, 'ascii')) + .digest('base64url'); +} + +function discloseArrayElement(value, sdAlg) { + const disclosure = encodeSdJwtDisclosure([randomSalt(), value]); + const digest = computeDisclosureDigest(disclosure, sdAlg); + return { disclosure, digest, placeholder: { '...': digest } }; +} + +function discloseClaim(claimName, value, sdAlg) { + const disclosure = encodeSdJwtDisclosure([randomSalt(), claimName, value]); + const digest = computeDisclosureDigest(disclosure, sdAlg); + return { disclosure, digest }; +} + +function decodeDisclosure(encodedDisclosure) { + return JSON.parse(Buffer.from(encodedDisclosure, 'base64url').toString('utf8')); +} + +function decodeArrayElementDisclosure(encodedDisclosure) { + const decoded = decodeDisclosure(encodedDisclosure); + if (!Array.isArray(decoded) || decoded.length !== 2) { + throw new Error('Expected a 2-element [salt, value] array-element disclosure'); + } + return decoded[1]; +} + +function decodeClaimDisclosure(encodedDisclosure) { + const decoded = decodeDisclosure(encodedDisclosure); + if (!Array.isArray(decoded) || decoded.length !== 3) { + throw new Error('Expected a 3-element [salt, claimName, value] disclosure'); + } + return { claimName: decoded[1], value: decoded[2] }; +} + +function findDisclosureByDigest(disclosures, digest, sdAlg) { + return disclosures.find( + (disclosure) => computeDisclosureDigest(disclosure, sdAlg) === digest, + ); +} + +// Shared by both redact*Constraints functions below: collects array-element +// disclosures into `disclosures` as `redact` is mapped over revealed values. +function createArrayElementRedactor(sdAlg) { + const disclosures = []; + const redact = (entry) => { + const d = discloseArrayElement(entry, sdAlg); + disclosures.push(d.disclosure); + return d.placeholder; + }; + return { redact, disclosures }; +} + +// Redacts the array-element-disclosable fields of an Open Checkout Mandate's +// constraints (checkout.line_items[].acceptable_items, checkout.allowed_merchants.allowed), +// per the property tables on the AP2 Checkout Mandate spec page. +function redactCheckoutConstraints(constraints, sdAlg) { + const { redact, disclosures } = createArrayElementRedactor(sdAlg); + + const redacted = (constraints ?? []).map((constraint) => { + if (constraint?.type === 'checkout.line_items' && Array.isArray(constraint.items)) { + return { + ...constraint, + items: constraint.items.map((item) => ( + Array.isArray(item?.acceptable_items) + ? { ...item, acceptable_items: item.acceptable_items.map(redact) } + : item + )), + }; + } + if (constraint?.type === 'checkout.allowed_merchants' && Array.isArray(constraint.allowed)) { + return { ...constraint, allowed: constraint.allowed.map(redact) }; + } + return constraint; + }); + + return { redacted, disclosures }; +} + +// Redacts the array-element-disclosable fields of an Open Payment Mandate's +// constraints (payment.allowed_payees.allowed), per the property tables on +// the AP2 Payment Mandate spec page. +function redactPaymentConstraints(constraints, sdAlg) { + const { redact, disclosures } = createArrayElementRedactor(sdAlg); + + const redacted = (constraints ?? []).map((constraint) => { + if (constraint?.type === 'payment.allowed_payees' && Array.isArray(constraint.allowed)) { + return { ...constraint, allowed: constraint.allowed.map(redact) }; + } + return constraint; + }); + + return { redacted, disclosures }; +} + +function wrapAsDelegatePayload(content, sdAlg) { + const d = discloseArrayElement(content, sdAlg); + return { delegatePayload: [d.placeholder], contentDisclosure: d.disclosure }; +} + +async function signMandateEnvelope(envelope, { signer, kid, typ }) { + if (signer == null || typeof signer.sign !== 'function') { + throw new TypeError('"signer" with a sign(data) function is required'); + } + return signJWT(signer, envelope, { + algorithm: 'ES256', + header: { typ, ...(kid === undefined ? {} : { kid }) }, + }); +} + +/** + * Validates and clones an Open Checkout Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildOpenCheckoutMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_CHECKOUT_OPEN); +} + +/** + * Validates and clones a Closed Checkout Mandate's disclosed content. + * `checkout_jwt` is present here in fully-revealed form; `signClosedCheckoutMandate` + * replaces it with an `_sd` digest. + * @param {object} content + * @returns {object} + */ +export function buildClosedCheckoutMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_CHECKOUT_CLOSED); +} + +/** + * Validates and clones an Open Payment Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildOpenPaymentMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_PAYMENT_OPEN); +} + +/** + * Validates and clones a Closed Payment Mandate's disclosed content. + * @param {object} content + * @returns {object} + */ +export function buildClosedPaymentMandate(content) { + return validateMandateContent(content, MANDATE_TYPE_PAYMENT_CLOSED); +} + +/** + * Signs an Open Checkout Mandate (mandate.checkout.open.1) as a compact + * SD-JWT presentation, signed with the user's key (BYOK `signer.sign(data)`, + * matching the receipts.js contract so a wallet-held key can be bridged in + * without ever exposing the private key to this package). + * + * @param {object} content Output of `buildOpenCheckoutMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signOpenCheckoutMandate(content, { signer, kid, sdAlg = DEFAULT_SD_ALG } = {}) { + const validated = buildOpenCheckoutMandate(content); + const { redacted, disclosures: constraintDisclosures } = redactCheckoutConstraints( + validated.constraints, + sdAlg, + ); + const redactedContent = { ...validated, constraints: redacted }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const envelope = { delegate_payload: delegatePayload, _sd_alg: sdAlg }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'dc+sd-jwt' }); + + return `${issuerJwt}~${[...constraintDisclosures, contentDisclosure].join('~')}~`; +} + +/** + * Signs an Open Payment Mandate (mandate.payment.open.1) as a compact + * SD-JWT presentation. See `signOpenCheckoutMandate` for the signer contract. + * + * @param {object} content Output of `buildOpenPaymentMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signOpenPaymentMandate(content, { signer, kid, sdAlg = DEFAULT_SD_ALG } = {}) { + const validated = buildOpenPaymentMandate(content); + const { redacted, disclosures: constraintDisclosures } = redactPaymentConstraints( + validated.constraints, + sdAlg, + ); + const redactedContent = { ...validated, constraints: redacted }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const envelope = { delegate_payload: delegatePayload, _sd_alg: sdAlg }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'dc+sd-jwt' }); + + return `${issuerJwt}~${[...constraintDisclosures, contentDisclosure].join('~')}~`; +} + +/** + * Signs a Closed Checkout Mandate (mandate.checkout.1) as a compact SD-JWT + * presentation, binding it back to a specific Open Checkout Mandate + * presentation via `sd_hash`. + * + * NOTE ON `sd_hash`: computed here as the RFC 9901 hash over the referenced + * Open Mandate's issuer-signed JWT + its disclosures (the same primitive as + * `computeSdHash`), consistent with the Agent Authorization Framework's + * "Mandate Receipt.reference... calculated in the same manner as sd_hash" + * language and with `sd_hash`'s standard RFC 9901 meaning. The AP2 spec's own + * worked example for the Closed Checkout Mandate shows a `sd_hash` value that + * instead numerically matches the digest of its own `checkout_jwt` + * disclosure — this looks like reused placeholder text in the docs (the same + * string also appears, implausibly, as a `payment.reference.conditional_transaction_id` + * value on an unrelated page) rather than a deliberate alternate meaning, but + * this has NOT been confirmed against a reference implementation or the + * normative "Delegate SD-JWT" individual draft this spec depends on for chain + * verification. Treat this as the best-supported reading, not a certainty. + * + * @param {object} content Output of `buildClosedCheckoutMandate` (with `checkout_jwt` revealed). + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, nonce: string, + * openMandatePresentation: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signClosedCheckoutMandate( + content, + { + signer, kid, nonce, openMandatePresentation, sdAlg = DEFAULT_SD_ALG, + } = {}, +) { + const validated = buildClosedCheckoutMandate(content); + if (typeof openMandatePresentation !== 'string' || openMandatePresentation.length === 0) { + throw new TypeError('"openMandatePresentation" is required to compute sd_hash'); + } + + const { checkout_jwt: checkoutJwt, ...rest } = validated; + const claimDisclosure = discloseClaim('checkout_jwt', checkoutJwt, sdAlg); + const redactedContent = { ...rest, _sd: [claimDisclosure.digest] }; + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(redactedContent, sdAlg); + + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const sdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + + const envelope = { + delegate_payload: delegatePayload, + iat: Math.floor(Date.now() / 1000), + aud: 'merchant', + nonce, + sd_hash: sdHash, + _sd_alg: sdAlg, + }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'kb+sd-jwt' }); + + return `${issuerJwt}~${[claimDisclosure.disclosure, contentDisclosure].join('~')}~`; +} + +/** + * Signs a Closed Payment Mandate (mandate.payment.1) as a compact SD-JWT + * presentation, binding it back to a specific Open Payment Mandate + * presentation via `sd_hash`. See `signClosedCheckoutMandate`'s doc comment + * for the same caveat about `sd_hash`'s exact semantics. + * + * @param {object} content Output of `buildClosedPaymentMandate`. + * @param {{signer: {sign: function(Uint8Array): *}, kid?: string, nonce: string, + * openMandatePresentation: string, sdAlg?: string}} options + * @returns {Promise} + */ +export async function signClosedPaymentMandate( + content, + { + signer, kid, nonce, openMandatePresentation, sdAlg = DEFAULT_SD_ALG, + } = {}, +) { + const validated = buildClosedPaymentMandate(content); + if (typeof openMandatePresentation !== 'string' || openMandatePresentation.length === 0) { + throw new TypeError('"openMandatePresentation" is required to compute sd_hash'); + } + + const { delegatePayload, contentDisclosure } = wrapAsDelegatePayload(validated, sdAlg); + + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const sdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + + const envelope = { + delegate_payload: delegatePayload, + iat: Math.floor(Date.now() / 1000), + aud: 'credential-provider', + nonce, + sd_hash: sdHash, + _sd_alg: sdAlg, + }; + const issuerJwt = await signMandateEnvelope(envelope, { signer, kid, typ: 'kb+sd-jwt' }); + + return `${issuerJwt}~${contentDisclosure}~`; +} + +function resolvePublicKey({ publicKey, holderJwk }) { + if (publicKey !== undefined) { + return publicKey; + } + if (holderJwk !== undefined) { + return jwkToSecp256r1PublicKey(holderJwk); + } + throw new TypeError('Either "publicKey" or "holderJwk" is required'); +} + +function resolveMandateContent(payload, disclosures, sdAlg) { + if (!Array.isArray(payload.delegate_payload) || payload.delegate_payload.length !== 1) { + throw new Error('"delegate_payload" must contain exactly one entry'); + } + const contentDigest = payload.delegate_payload[0]?.['...']; + if (typeof contentDigest !== 'string') { + throw new Error('"delegate_payload" entry must be a digest placeholder'); + } + const contentDisclosure = findDisclosureByDigest(disclosures, contentDigest, sdAlg); + if (!contentDisclosure) { + throw new Error('No disclosure found for the delegate_payload digest'); + } + return decodeArrayElementDisclosure(contentDisclosure); +} + +function getSdAlg(payload) { + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + return payload._sd_alg || DEFAULT_SD_ALG; +} + +function checkNotExpired(payload, { currentDate, clockTolerance }, label) { + if (typeof payload.exp !== 'number') { + return; + } + const now = Math.floor(currentDate.getTime() / 1000); + if (payload.exp + clockTolerance < now) { + throw new Error(`${label} has expired`); + } +} + +function checkAudience(payload, expectedAud) { + if (payload.aud !== expectedAud) { + throw new Error(`Expected envelope "aud" of "${expectedAud}", got "${payload.aud}"`); + } +} + +// Returns `true` if `sd_hash` was checked, `undefined` if `openMandatePresentation` +// was not supplied (verification of that binding is then left to the caller). +function checkSdHashAgainstOpenMandate(payload, openMandatePresentation, label) { + if (openMandatePresentation === undefined) { + return undefined; + } + const { issuerJwt: openIssuerJwt, disclosures: openDisclosures } = parseSdJwtPresentation( + openMandatePresentation, + ); + const expectedSdHash = computeSdHash({ issuerJwt: openIssuerJwt, disclosures: openDisclosures }); + if (payload.sd_hash !== expectedSdHash) { + throw new Error(`"sd_hash" does not match the referenced ${label} presentation`); + } + return true; +} + +// Resolves the checkout_jwt claim disclosure referenced by a Closed Checkout +// Mandate content's "_sd" digest array, returning the revealed checkout_jwt +// string. Throws if the digest, disclosure, or claim name don't line up. +function resolveCheckoutJwtClaim(content, disclosures, sdAlg) { + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + const sdDigests = content._sd; + if (!Array.isArray(sdDigests) || typeof sdDigests[0] !== 'string') { + throw new Error('Closed Checkout Mandate content must have an "_sd" digest for checkout_jwt'); + } + const claimDisclosure = findDisclosureByDigest(disclosures, sdDigests[0], sdAlg); + if (!claimDisclosure) { + throw new Error('No disclosure found for the checkout_jwt digest'); + } + const { claimName, value: checkoutJwt } = decodeClaimDisclosure(claimDisclosure); + if (claimName !== 'checkout_jwt') { + throw new Error(`Expected a "checkout_jwt" disclosure, got "${claimName}"`); + } + return checkoutJwt; +} + +/** + * Verifies a Closed Checkout Mandate presentation. + * + * Verifies: the envelope signature against `publicKey` (or `holderJwk`, the + * agent's `cnf.jwk` from the referenced Open Checkout Mandate — pass either), + * the `checkout_jwt` disclosure against the content's `_sd` digest, + * `checkout_hash` against a fresh hash of the revealed `checkout_jwt`, + * `aud === "merchant"`, and — when `openMandatePresentation` is supplied — + * `sd_hash` against that presentation. See `signClosedCheckoutMandate` for + * the `sd_hash` caveat. + * + * @param {string} presentation + * @param {{publicKey?: *, holderJwk?: object, openMandatePresentation?: string, + * currentDate?: Date, clockTolerance?: number}} options + * @returns {{verified: boolean, content?: object, checkoutJwt?: string, + * protectedHeader?: object, sdHashVerified?: boolean, error?: Error}} + */ +export function verifyClosedCheckoutMandate( + presentation, + { + publicKey, holderJwk, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + } = {}, +) { + try { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + if (!result.verified) { + return result; + } + const { payload, protectedHeader } = result; + const sdAlg = getSdAlg(payload); + + const content = resolveMandateContent(payload, disclosures, sdAlg); + const checkoutJwt = resolveCheckoutJwtClaim(content, disclosures, sdAlg); + + const revealedContent = { ...content, checkout_jwt: checkoutJwt }; + // eslint-disable-next-line no-underscore-dangle -- protocol field being stripped before validation + delete revealedContent._sd; + const validatedContent = buildClosedCheckoutMandate(revealedContent); + if (validatedContent.checkout_hash !== computeCheckoutHash(checkoutJwt, sdAlg)) { + throw new Error('"checkout_hash" does not match a fresh hash of "checkout_jwt"'); + } + + checkAudience(payload, 'merchant'); + checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Checkout Mandate'); + const sdHashVerified = checkSdHashAgainstOpenMandate( + payload, + openMandatePresentation, + 'Open Checkout Mandate', + ); + + return { + verified: true, + content: validatedContent, + checkoutJwt, + protectedHeader, + ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + }; + } catch (error) { + return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; + } +} + +/** + * Verifies a Closed Payment Mandate presentation. + * + * Verifies: the envelope signature against `publicKey` (the agent's `cnf` + * key from the referenced Open Payment Mandate), `aud === "credential-provider"`, + * and — when the corresponding Closed Checkout Mandate's `checkout_jwt` is + * supplied — that `transaction_id` matches a fresh hash of it (the same + * `checkout_hash` computation). When `openMandatePresentation` is supplied, + * also verifies `sd_hash` against it. See `signClosedCheckoutMandate` for the + * `sd_hash` caveat. + * + * This does not verify the `conditional_transaction_id` binding (Open Payment + * Mandate's `payment.reference` constraint against the checkout's delegate + * chain) — that check requires the checkout's full delegate chain and is + * left to the caller, which is expected to already hold both mandates when + * assembling a payment authorization decision. + * + * @param {string} presentation + * @param {{publicKey?: *, holderJwk?: object, checkoutJwt?: string, openMandatePresentation?: string, + * currentDate?: Date, clockTolerance?: number}} options + * @returns {{verified: boolean, content?: object, protectedHeader?: object, + * transactionIdVerified?: boolean, sdHashVerified?: boolean, error?: Error}} + */ +export function verifyClosedPaymentMandate( + presentation, + { + publicKey, holderJwk, checkoutJwt, openMandatePresentation, currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE, + } = {}, +) { + try { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const result = verifyJWT(issuerJwt, resolvePublicKey({ publicKey, holderJwk }), { algorithms: ['ES256'] }); + if (!result.verified) { + return result; + } + const { payload, protectedHeader } = result; + const sdAlg = getSdAlg(payload); + + const content = resolveMandateContent(payload, disclosures, sdAlg); + const validatedContent = buildClosedPaymentMandate(content); + + checkAudience(payload, 'credential-provider'); + checkNotExpired(payload, { currentDate, clockTolerance }, 'Closed Payment Mandate'); + + let transactionIdVerified; + if (checkoutJwt !== undefined) { + if (validatedContent.transaction_id !== computeCheckoutHash(checkoutJwt, sdAlg)) { + throw new Error('"transaction_id" does not match a fresh hash of the provided checkout_jwt'); + } + transactionIdVerified = true; + } + + const sdHashVerified = checkSdHashAgainstOpenMandate( + payload, + openMandatePresentation, + 'Open Payment Mandate', + ); + + return { + verified: true, + content: validatedContent, + protectedHeader, + ...(transactionIdVerified === undefined ? {} : { transactionIdVerified }), + ...(sdHashVerified === undefined ? {} : { sdHashVerified }), + }; + } catch (error) { + return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; + } +} diff --git a/packages/ap2/src/schemas/checkout-mandate-closed.json b/packages/ap2/src/schemas/checkout-mandate-closed.json new file mode 100644 index 000000000..987fecbea --- /dev/null +++ b/packages/ap2/src/schemas/checkout-mandate-closed.json @@ -0,0 +1,27 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Closed Checkout Mandate Content", + "description": "The disclosed content of a Closed Checkout Mandate (mandate.checkout.1). checkout_jwt is the fully-revealed input to signing; it is replaced by an _sd digest (object-property disclosure) at signing time, leaving checkout_hash as the caller-visible binding value.", + "type": "object", + "properties": { + "vct": { + "const": "mandate.checkout.1" + }, + "checkout_jwt": { + "type": "string", + "description": "The merchant-signed Checkout JWT, base64url-encoded. Present in the fully-revealed content passed to signClosedMandate; replaced by an _sd digest in the final disclosed/signed form." + }, + "checkout_hash": { + "type": "string", + "description": "base64url hash of checkout_jwt." + }, + "iat": { + "type": "integer" + }, + "exp": { + "type": "integer" + } + }, + "required": ["vct", "checkout_jwt", "checkout_hash"], + "additionalProperties": true +} diff --git a/packages/ap2/src/schemas/checkout-mandate-open.json b/packages/ap2/src/schemas/checkout-mandate-open.json new file mode 100644 index 000000000..336575bca --- /dev/null +++ b/packages/ap2/src/schemas/checkout-mandate-open.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Open Checkout Mandate Content", + "description": "The disclosed content of an Open Checkout Mandate (mandate.checkout.open.1). This describes the fully-revealed logical shape; array-element selective disclosure of constraints[].items[].acceptable_items and constraints[].allowed is applied separately at signing time.", + "type": "object", + "properties": { + "vct": { + "const": "mandate.checkout.open.1" + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + } + } + } + }, + "cnf": { + "type": "object", + "properties": { + "jwk": { + "type": "object", + "required": ["kty", "crv", "x", "y"], + "properties": { + "kty": { "const": "EC" }, + "crv": { "const": "P-256" }, + "x": { "type": "string" }, + "y": { "type": "string" } + } + } + }, + "required": ["jwk"] + }, + "iat": { + "type": "integer", + "description": "Creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "Expiration timestamp as a Unix epoch." + } + }, + "required": ["vct", "constraints", "cnf"], + "additionalProperties": true +} diff --git a/packages/ap2/src/schemas/payment-mandate-closed.json b/packages/ap2/src/schemas/payment-mandate-closed.json new file mode 100644 index 000000000..259f526b1 --- /dev/null +++ b/packages/ap2/src/schemas/payment-mandate-closed.json @@ -0,0 +1,52 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Closed Payment Mandate Content", + "description": "The disclosed content of a Closed Payment Mandate (mandate.payment.1).", + "type": "object", + "properties": { + "vct": { + "const": "mandate.payment.1" + }, + "transaction_id": { + "type": "string", + "description": "base64url hash of the associated Checkout Mandate's checkout_jwt (same value as that mandate's checkout_hash)." + }, + "payee": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "website": { "type": "string" } + } + }, + "payment_amount": { + "type": "object", + "required": ["amount", "currency"], + "properties": { + "amount": { "type": "number" }, + "currency": { "type": "string" } + } + }, + "payment_instrument": { + "type": "object", + "required": ["id", "type"], + "properties": { + "id": { "type": "string" }, + "type": { "type": "string" }, + "description": { "type": "string" } + } + }, + "pisp": { + "type": "object" + }, + "execution_date": { + "type": "string" + }, + "risk_data": { + "type": "object" + } + }, + "required": ["vct", "transaction_id", "payee", "payment_amount", "payment_instrument"], + "additionalProperties": true +} diff --git a/packages/ap2/src/schemas/payment-mandate-open.json b/packages/ap2/src/schemas/payment-mandate-open.json new file mode 100644 index 000000000..f5618b196 --- /dev/null +++ b/packages/ap2/src/schemas/payment-mandate-open.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Open Payment Mandate Content", + "description": "The disclosed content of an Open Payment Mandate (mandate.payment.open.1). This describes the fully-revealed logical shape; array-element selective disclosure of constraints[].allowed (payment.allowed_payees) is applied separately at signing time.", + "type": "object", + "properties": { + "vct": { + "const": "mandate.payment.open.1" + }, + "constraints": { + "type": "array", + "items": { + "type": "object", + "required": ["type"], + "properties": { + "type": { + "type": "string" + } + } + } + }, + "cnf": { + "type": "object", + "properties": { + "jwk": { + "type": "object", + "required": ["kty", "crv", "x", "y"], + "properties": { + "kty": { "const": "EC" }, + "crv": { "const": "P-256" }, + "x": { "type": "string" }, + "y": { "type": "string" } + } + } + }, + "required": ["jwk"] + }, + "iat": { + "type": "integer" + }, + "exp": { + "type": "integer" + } + }, + "required": ["vct", "constraints", "cnf"], + "additionalProperties": true +} diff --git a/packages/ap2/src/utils.js b/packages/ap2/src/utils.js index 3caf1d531..d0fe604e4 100644 --- a/packages/ap2/src/utils.js +++ b/packages/ap2/src/utils.js @@ -2,6 +2,10 @@ import Ajv from 'ajv'; import checkoutReceiptSchema from './schemas/checkout-receipt.json'; import paymentReceiptSchema from './schemas/payment-receipt.json'; +import checkoutMandateOpenSchema from './schemas/checkout-mandate-open.json'; +import checkoutMandateClosedSchema from './schemas/checkout-mandate-closed.json'; +import paymentMandateOpenSchema from './schemas/payment-mandate-open.json'; +import paymentMandateClosedSchema from './schemas/payment-mandate-closed.json'; export { encodeSdJwtDisclosure as encodeDisclosure, @@ -11,11 +15,29 @@ export const RECEIPT_TYPE_CHECKOUT = 'checkout'; export const RECEIPT_TYPE_PAYMENT = 'payment'; export const DEFAULT_CLOCK_TOLERANCE = 30; +export const MANDATE_TYPE_CHECKOUT_OPEN = 'checkout-open'; +export const MANDATE_TYPE_CHECKOUT_CLOSED = 'checkout-closed'; +export const MANDATE_TYPE_PAYMENT_OPEN = 'payment-open'; +export const MANDATE_TYPE_PAYMENT_CLOSED = 'payment-closed'; + +export const MANDATE_VCT = { + [MANDATE_TYPE_CHECKOUT_OPEN]: 'mandate.checkout.open.1', + [MANDATE_TYPE_CHECKOUT_CLOSED]: 'mandate.checkout.1', + [MANDATE_TYPE_PAYMENT_OPEN]: 'mandate.payment.open.1', + [MANDATE_TYPE_PAYMENT_CLOSED]: 'mandate.payment.1', +}; + const ajv = new Ajv({ allErrors: true, strict: false }); const receiptValidators = { [RECEIPT_TYPE_CHECKOUT]: ajv.compile(checkoutReceiptSchema), [RECEIPT_TYPE_PAYMENT]: ajv.compile(paymentReceiptSchema), }; +const mandateContentValidators = { + [MANDATE_TYPE_CHECKOUT_OPEN]: ajv.compile(checkoutMandateOpenSchema), + [MANDATE_TYPE_CHECKOUT_CLOSED]: ajv.compile(checkoutMandateClosedSchema), + [MANDATE_TYPE_PAYMENT_OPEN]: ajv.compile(paymentMandateOpenSchema), + [MANDATE_TYPE_PAYMENT_CLOSED]: ajv.compile(paymentMandateClosedSchema), +}; function formatValidationErrors(errors = []) { return errors @@ -46,6 +68,23 @@ export function validateReceipt(receipt, type) { return { ...receipt }; } +export function validateMandateContent(content, type) { + if (content == null || typeof content !== 'object' || Array.isArray(content)) { + throw new TypeError('Mandate content must be an object'); + } + const validate = mandateContentValidators[type]; + if (!validate) { + throw new Error(`Unsupported mandate type: ${type}`); + } + if (!validate(content)) { + throw new TypeError( + `Invalid ${type} mandate content: ${formatValidationErrors(validate.errors)}`, + ); + } + + return { ...content }; +} + export function inferReceiptType(receipt) { return receipt != null && typeof receipt === 'object' From f69310ecb95c6f42be240afbf9c50001c1b32b6f Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 10:47:05 -0400 Subject: [PATCH 2/9] test(ap2): add mandate tests, including spec byte-level verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-trips Open/Closed Checkout and Payment Mandates through real Secp256r1Keypair signing and verification, plus negative cases (tampered checkout_hash, wrong signing key). Also decodes the AP2 spec's own literal encoded token from the checkout_mandate page and asserts this package's digest computation matches it exactly — the only way to catch a subtly-wrong digest algorithm rather than one that's merely self-consistent. This caught two transcription typos while writing the test (independently confirmed via plain node:crypto that the spec page's human-readable JSON annotation has a one-character typo, not this code). Co-Authored-By: Claude Sonnet 5 --- packages/ap2/tests/mandates.test.js | 242 ++++++++++++++++++++++++++++ 1 file changed, 242 insertions(+) create mode 100644 packages/ap2/tests/mandates.test.js diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js new file mode 100644 index 000000000..bcc686196 --- /dev/null +++ b/packages/ap2/tests/mandates.test.js @@ -0,0 +1,242 @@ +import { + Secp256r1Keypair, +} from '@docknetwork/crypto-utils/keypairs'; +import { + parseSdJwtPresentation, + decodeJwtPayload, + secp256r1PublicKeyToJwk, +} from '@docknetwork/crypto-utils/vc'; + +import { + buildOpenCheckoutMandate, + buildClosedCheckoutMandate, + buildOpenPaymentMandate, + buildClosedPaymentMandate, + signOpenCheckoutMandate, + signClosedCheckoutMandate, + signOpenPaymentMandate, + signClosedPaymentMandate, + verifyClosedCheckoutMandate, + verifyClosedPaymentMandate, + computeDisclosureDigest, + computeCheckoutHash, +} from '../src'; + +// The exact encoded Open Checkout Mandate token from the AP2 spec's +// checkout_mandate page (agent-provider-key-1, "example+sd-jwt"), used to +// confirm this package's digest computation matches the spec's own bytes +// rather than only being self-consistent with itself. +const VCT_CHECKOUT_OPEN = 'mandate.checkout.open.1'; +const VCT_CHECKOUT_CLOSED = 'mandate.checkout.1'; +const SAMPLE_CHECKOUT_JWT = 'eyJhbGciOiJFUzI1NiJ9.eyJvcmRlcl9pZCI6Im9yZGVyLTEifQ.sig'; + +const SPEC_OPEN_CHECKOUT_TOKEN = 'eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJhZ2VudC1wcm92aWRlci1rZXktMSJ9.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIlF0WFRKdFdxZzk5OUNtVVdHakhGVFdNa1JQZ3VEZmVLM3dHU2FJbmQtZHcifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.HvCGk7ye_c0LN2-NFG13wfyu3LA--rckTPGm36ugO2aRvsded7ngw1py8W3JF7wBpoQnsKr17tNTF3zLeYcoWA~WyI0bjNMXy0zX0ZtMkdneUZBRjhDdF9nIiwgeyJpZCI6ICJzdXBlcnNob2VfbGltaXRlZF9lZGl0aW9uX2dvbGRfc25lYWtlcl93b21lbnNfOV8wIiwgInRpdGxlIjogIlN1cGVyU2hvZSBMaW1pdGVkIEVkaXRpb24gR29sZCJ9XQ~WyIyelBMNnZxTEJnMldZQWRiVzktMWxRIiwgeyJpZCI6ICJtZXJjaGFudF8xIiwgIm5hbWUiOiAiRGVtbyBNZXJjaGFudCIsICJ3ZWJzaXRlIjogImh0dHBzOi8vZGVtby1tZXJjaGFudC5leGFtcGxlIn1d~WyJsYUFvV0tOUnVHbndSRWpKV1lKN3BnIiwgeyJ2Y3QiOiAibWFuZGF0ZS5jaGVja291dC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbeyJ0eXBlIjogImNoZWNrb3V0LmxpbmVfaXRlbXMiLCAiaXRlbXMiOiBbeyJpZCI6ICJsaW5lXzEiLCAiYWNjZXB0YWJsZV9pdGVtcyI6IFt7Ii4uLiI6ICJ5M2FvY0FEMnJoWXBKUU9VTU4wMTZmYURGR2tUQkdFRFZsMVIxVFJIZGJ3In1dLCAicXVhbnRpdHkiOiAxfV19LCB7InR5cGUiOiAiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMiLCAiYWxsb3dlZCI6IFt7Ii4uLiI6ICJhNVVNQWR4Q2tfTVJheXlWZFJocElBWjBaaGpWTEVxMWcyQld5cndLVXdnIn1dfV0sICJjbmYiOiB7Imp3ayI6IHsiY3J2IjogIlAtMjU2IiwgImt0eSI6ICJFQyIsICJ4IjogIlFwU3l4UFFIeTM4eGNreXZEcjU0Z1ozVDQyemo5aUx0VjRrb3liNVUyN2MiLCAieSI6ICIzN0hMZDdKSmlueGpKSW44SjdIaWpzc29lY0JsZmhkVy1nVUw3ZmVJOWx3In19LCAiaWF0IjogMTc3NzM0MjM1NywgImV4cCI6IDE3NzczNDU5NTd9XQ~'; + +describe('AP2 mandates: spec byte-level verification', () => { + test('digest computation matches the AP2 checkout_mandate spec page example', () => { + const { issuerJwt, disclosures } = parseSdJwtPresentation(SPEC_OPEN_CHECKOUT_TOKEN); + const payload = decodeJwtPayload(issuerJwt); + + // eslint-disable-next-line no-underscore-dangle -- protocol-mandated claim name + expect(payload._sd_alg).toBe('sha-256'); + expect(payload.delegate_payload).toEqual([ + { '...': 'QtXTJtWqg999CmUWGjHFTWMkRPguDfeK3wGSaInd-dw' }, + ]); + expect(disclosures).toHaveLength(3); + + const digests = disclosures.map((d) => computeDisclosureDigest(d, 'sha-256')); + // The spec page's own pretty-printed JSON annotates the merchant + // disclosure's digest as "...ruKUwg", but independently recomputing + // sha256(base64url-disclosure) directly from the real compact token's + // bytes (outside this library, via plain node:crypto) yields "...rwKUwg" — + // a one-character typo in the docs' human-readable illustration, not a + // bug here. The other two digests (including the top-level + // delegate_payload digest) match the docs exactly. + expect(digests).toEqual([ + 'y3aocAD2rhYpJQOUMN016faDFGkTBGEDVl1R1TRHdbw', + 'a5UMAdxCk_MRayyVdRhpIAZ0ZhjVLEq1g2BWyrwKUwg', + 'QtXTJtWqg999CmUWGjHFTWMkRPguDfeK3wGSaInd-dw', + ]); + + // The last disclosure's digest is the one referenced by delegate_payload. + const contentDisclosure = disclosures[2]; + const decodedContent = JSON.parse( + Buffer.from(contentDisclosure, 'base64url').toString('utf8'), + ); + expect(decodedContent[1]).toMatchObject({ + vct: VCT_CHECKOUT_OPEN, + cnf: { + jwk: { + crv: 'P-256', + kty: 'EC', + x: 'QpSyxPQHy38xckyvDr54gZ3T42zj9iLtV4koyb5U27c', + y: '37HLd7JJinxjJIn8J7HijssoecBlfhdW-gUL7feI9lw', + }, + }, + }); + }); +}); + +describe('AP2 mandates: round trip', () => { + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + + test('signs and verifies an autonomous Checkout + Payment mandate chain', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutContent = buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [ + { + type: 'checkout.line_items', + items: [ + { + id: 'line_1', + quantity: 1, + acceptable_items: [{ id: 'SKU-1', title: 'Widget' }], + }, + ], + }, + { + type: 'checkout.allowed_merchants', + allowed: [merchant], + }, + ], + cnf, + iat: 1000, + exp: 100000, + }); + const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { + signer: userKeypair, + }); + + const checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutContent = buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: computeCheckoutHash(checkoutJwt), + }); + const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }); + + const checkoutResult = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: agentKeypair.publicKey(), + openMandatePresentation: openCheckoutPresentation, + }); + expect(checkoutResult.verified).toBe(true); + expect(checkoutResult.checkoutJwt).toBe(checkoutJwt); + expect(checkoutResult.sdHashVerified).toBe(true); + + const openPaymentContent = buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [ + { + type: 'payment.amount_range', currency: 'USD', min: 0, max: 20000, + }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + ], + cnf, + iat: 1000, + exp: 100000, + }); + const openPaymentPresentation = await signOpenPaymentMandate(openPaymentContent, { + signer: userKeypair, + }); + + const closedPaymentContent = buildClosedPaymentMandate({ + vct: 'mandate.payment.1', + transaction_id: computeCheckoutHash(checkoutJwt), + payee: merchant, + payment_amount: { amount: 19900, currency: 'USD' }, + payment_instrument: { id: 'stub', type: 'card', description: 'Card ****4242' }, + }); + const closedPaymentPresentation = await signClosedPaymentMandate(closedPaymentContent, { + signer: agentKeypair, + nonce: 'nonce-2', + openMandatePresentation: openPaymentPresentation, + }); + + const paymentResult = verifyClosedPaymentMandate(closedPaymentPresentation, { + publicKey: agentKeypair.publicKey(), + checkoutJwt, + openMandatePresentation: openPaymentPresentation, + }); + expect(paymentResult.verified).toBe(true); + expect(paymentResult.transactionIdVerified).toBe(true); + expect(paymentResult.sdHashVerified).toBe(true); + }); + + test('rejects a Closed Checkout Mandate with a tampered checkout_hash', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [], + cnf, + }), + { signer: userKeypair }, + ); + + const checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: 'not-the-real-hash', + }), + { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: agentKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + expect(result.error.message).toMatch(/checkout_hash/); + }); + + test('rejects a Closed Checkout Mandate signed by the wrong key', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const otherKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const openCheckoutPresentation = await signOpenCheckoutMandate( + buildOpenCheckoutMandate({ + vct: VCT_CHECKOUT_OPEN, + constraints: [], + cnf, + }), + { signer: userKeypair }, + ); + + const checkoutJwt = SAMPLE_CHECKOUT_JWT; + const closedCheckoutPresentation = await signClosedCheckoutMandate( + buildClosedCheckoutMandate({ + vct: VCT_CHECKOUT_CLOSED, + checkout_jwt: checkoutJwt, + checkout_hash: computeCheckoutHash(checkoutJwt), + }), + { + signer: agentKeypair, + nonce: 'nonce-1', + openMandatePresentation: openCheckoutPresentation, + }, + ); + + const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + publicKey: otherKeypair.publicKey(), + }); + expect(result.verified).toBe(false); + }); +}); From c4f042bd6698f0fb9d3db70ba753979dc5817b59 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 10:47:14 -0400 Subject: [PATCH 3/9] docs(ap2): document mandates, add changeset Adds a Mandates section to the README (usage example, sd_hash caveat) and removes the now-outdated "mandate verification lives outside this package" line. Adds a changeset (minor bump) per this monorepo's changesets-based release process. Co-Authored-By: Claude Sonnet 5 --- .changeset/tame-crabs-relax.md | 13 +++++ packages/ap2/README.md | 91 +++++++++++++++++++++++++++++++--- 2 files changed, 96 insertions(+), 8 deletions(-) create mode 100644 .changeset/tame-crabs-relax.md diff --git a/.changeset/tame-crabs-relax.md b/.changeset/tame-crabs-relax.md new file mode 100644 index 000000000..9492eb02d --- /dev/null +++ b/.changeset/tame-crabs-relax.md @@ -0,0 +1,13 @@ +--- +"@docknetwork/ap2": minor +--- + +Add AP2 v0.2 mandate support: build/sign/verify functions for Open and +Closed Checkout Mandates and Payment Mandates +(`buildOpenCheckoutMandate`, `signOpenCheckoutMandate`, +`buildClosedCheckoutMandate`, `signClosedCheckoutMandate`, +`verifyClosedCheckoutMandate`, `buildOpenPaymentMandate`, +`signOpenPaymentMandate`, `buildClosedPaymentMandate`, +`signClosedPaymentMandate`, `verifyClosedPaymentMandate`, +`computeCheckoutHash`, `computeDisclosureDigest`). Mandate verification now +lives in this package instead of requiring an external dependency. diff --git a/packages/ap2/README.md b/packages/ap2/README.md index 09e5976d5..b32a1f1ff 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -1,8 +1,8 @@ # @docknetwork/ap2 -[AP2](https://ap2-protocol.org/) receipt issuance and verification using the -lightweight Dock crypto utilities. Receipts are compact ES256 JWTs compatible -with the AP2 receipt format. +[AP2](https://ap2-protocol.org/) mandate and receipt issuance/verification +using the lightweight Dock crypto utilities. Mandates and receipts are +compact ES256 SD-JWTs/JWTs compatible with the AP2 v0.2 spec. See the [AP2 documentation](https://ap2-protocol.org/) and [specification](https://ap2-protocol.org/ap2/specification/) for protocol @@ -14,10 +14,9 @@ import { computeMandateReference, signReceipt, verifyPaymentReceipt, + verifyClosedPaymentMandate, } from '@docknetwork/ap2'; import { Secp256r1Keypair } from '@docknetwork/crypto-utils/keypairs'; -// Mandate verification lives outside this package (e.g. @ar-agents/ap2). -import { verifyClosedPaymentMandate } from '@ar-agents/ap2'; const keypair = Secp256r1Keypair.random(); // Use the exact compact presentation received from the Shopping Agent. @@ -34,10 +33,10 @@ const jwt = await signReceipt(receipt, { signer: keypair, type: 'payment', }); -// Verify the mandate first (signatures, delegation, audience, etc.). -const mandateVerification = await verifyClosedPaymentMandate( +// Verify the mandate first (signature, cnf key-binding, checkout binding, aud, expiry). +const mandateVerification = verifyClosedPaymentMandate( closedMandatePresentation, - { /* issuer keys, expected transaction id, ... */ }, + { /* publicKey or holderJwk, checkoutJwt, openMandatePresentation, ... */ }, ); const result = verifyPaymentReceipt(jwt, { @@ -90,6 +89,82 @@ selects its final SD-JWT, removes a trailing key-binding JWT, and delegates to that primitive. `encodeDisclosure([salt, claimName, value])` creates the base64url-encoded disclosure form used by both functions. +## Mandates + +AP2 v0.2 defines two mandate families — **Checkout Mandate** and **Payment +Mandate** — each with an **Open** and **Closed** state. Mandates are +self-signed SD-JWTs: Open Mandates are signed by the user's key (via a +Trusted Surface, e.g. a wallet), Closed Mandates by the Shopping Agent's key +endorsed in the Open Mandate's `cnf` claim. This package builds, signs, and +verifies all four shapes: + +```js +import { + buildOpenCheckoutMandate, + signOpenCheckoutMandate, + buildClosedCheckoutMandate, + signClosedCheckoutMandate, + verifyClosedCheckoutMandate, + buildOpenPaymentMandate, + signOpenPaymentMandate, + buildClosedPaymentMandate, + signClosedPaymentMandate, + verifyClosedPaymentMandate, + computeCheckoutHash, +} from '@docknetwork/ap2'; + +// 1. User's wallet signs an Open Checkout Mandate (once, up front). +const openCheckoutContent = buildOpenCheckoutMandate({ + vct: 'mandate.checkout.open.1', + constraints: [/* checkout.line_items, checkout.allowed_merchants */], + cnf: { jwk: agentPublicJwk }, +}); +const openCheckoutPresentation = await signOpenCheckoutMandate(openCheckoutContent, { + signer: userSigner, +}); + +// 2. Later, the Shopping Agent closes it against a specific merchant checkout. +const closedCheckoutContent = buildClosedCheckoutMandate({ + vct: 'mandate.checkout.1', + checkout_jwt: merchantSignedCheckoutJwt, + checkout_hash: computeCheckoutHash(merchantSignedCheckoutJwt), +}); +const closedCheckoutPresentation = await signClosedCheckoutMandate(closedCheckoutContent, { + signer: agentSigner, + nonce: 'merchant-supplied-nonce', + openMandatePresentation: openCheckoutPresentation, +}); + +// 3. The Merchant/Credential Provider verifies it. +const result = verifyClosedCheckoutMandate(closedCheckoutPresentation, { + holderJwk: agentPublicJwk, // or publicKey + openMandatePresentation: openCheckoutPresentation, +}); +if (!result.verified) throw result.error; +``` + +Payment Mandates follow the same `build*`/`sign*`/`verifyClosedPaymentMandate` +pattern, binding to a Checkout Mandate via `transaction_id` (a hash of +`checkout_jwt`, verified against a `checkoutJwt` passed to +`verifyClosedPaymentMandate`). + +The `signer` for both signing functions uses the same BYOK +`signer.sign(data)` contract as `signReceipt` — bridge it to a wallet-held key +without ever exposing the private key to this package. + +**Known caveat:** `sd_hash` (the claim binding a Closed Mandate back to its +Open Mandate) is implemented here as the RFC 9901 hash of the referenced Open +Mandate's full presentation (`computeSdHash`), matching the Agent +Authorization Framework's "Mandate Receipt... calculated in the same manner +as sd_hash" language. The AP2 spec's own worked example for the Closed +Checkout Mandate shows an `sd_hash` value that instead numerically matches +the digest of its own `checkout_jwt` disclosure — this looks like a reused +placeholder string in the docs rather than a deliberate alternate meaning +(the same string implausibly also appears as an unrelated +`conditional_transaction_id` example elsewhere), but it has not been +confirmed against a reference implementation or the normative "Delegate +SD-JWT" individual draft this spec depends on for chain verification. + ## Verification guarantees Receipt verification: From e439cbfcaeb0d67dd50bd0fecfe256a38b255a4c Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 11:46:03 -0400 Subject: [PATCH 4/9] remove claude changeset file --- .changeset/tame-crabs-relax.md | 13 ------------- 1 file changed, 13 deletions(-) delete mode 100644 .changeset/tame-crabs-relax.md diff --git a/.changeset/tame-crabs-relax.md b/.changeset/tame-crabs-relax.md deleted file mode 100644 index 9492eb02d..000000000 --- a/.changeset/tame-crabs-relax.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -"@docknetwork/ap2": minor ---- - -Add AP2 v0.2 mandate support: build/sign/verify functions for Open and -Closed Checkout Mandates and Payment Mandates -(`buildOpenCheckoutMandate`, `signOpenCheckoutMandate`, -`buildClosedCheckoutMandate`, `signClosedCheckoutMandate`, -`verifyClosedCheckoutMandate`, `buildOpenPaymentMandate`, -`signOpenPaymentMandate`, `buildClosedPaymentMandate`, -`signClosedPaymentMandate`, `verifyClosedPaymentMandate`, -`computeCheckoutHash`, `computeDisclosureDigest`). Mandate verification now -lives in this package instead of requiring an external dependency. From 6840c2285adc30e63f6bd12638894707f9122901 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 11:59:04 -0400 Subject: [PATCH 5/9] fix(ap2): align mandate schemas with Truvera's published JSON Schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the 4 hand-authored mandate content schemas with the canonical versions published at schema.truvera.io last week (adding their $id/ $metadata), which are meaningfully more precise than what was written from just reading the spec's property tables: - payment_amount.amount is integer minor units (not a bare number) - Open Checkout Mandate constraints must contain at least one checkout.line_items entry (schema "contains", not just conventional) - Open Payment Mandate constraints must contain a payment.reference entry with conditional_transaction_id — also schema-required - Full constraint type coverage for Open Payment Mandate via proper $defs/oneOf: payment.agent_recurrence, allowed_payees, allowed_payment_instruments, allowed_pisps, amount_range, budget, execution_date, reference (previously only amount_range and allowed_payees were modeled at all) Updates redactPaymentConstraints to also treat payment.allowed_payment_instruments as array-element-disclosable, matching the "x-selectively-disclosable-array" annotation on the published schema (payment.allowed_pisps is not marked disclosable and is left unredacted, same as before). Co-Authored-By: Claude Sonnet 5 --- packages/ap2/src/mandates.js | 9 +- .../src/schemas/checkout-mandate-closed.json | 33 +- .../src/schemas/checkout-mandate-open.json | 153 ++++++-- .../src/schemas/payment-mandate-closed.json | 149 ++++++-- .../ap2/src/schemas/payment-mandate-open.json | 328 ++++++++++++++++-- 5 files changed, 577 insertions(+), 95 deletions(-) diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index d4af03486..9c9303d2b 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -155,13 +155,16 @@ function redactCheckoutConstraints(constraints, sdAlg) { } // Redacts the array-element-disclosable fields of an Open Payment Mandate's -// constraints (payment.allowed_payees.allowed), per the property tables on -// the AP2 Payment Mandate spec page. +// constraints (payment.allowed_payees.allowed and +// payment.allowed_payment_instruments.allowed — marked +// "x-selectively-disclosable-array" in the published Truvera schema; +// payment.allowed_pisps.allowed is not marked disclosable and is left as-is). function redactPaymentConstraints(constraints, sdAlg) { const { redact, disclosures } = createArrayElementRedactor(sdAlg); + const disclosableTypes = ['payment.allowed_payees', 'payment.allowed_payment_instruments']; const redacted = (constraints ?? []).map((constraint) => { - if (constraint?.type === 'payment.allowed_payees' && Array.isArray(constraint.allowed)) { + if (disclosableTypes.includes(constraint?.type) && Array.isArray(constraint.allowed)) { return { ...constraint, allowed: constraint.allowed.map(redact) }; } return constraint; diff --git a/packages/ap2/src/schemas/checkout-mandate-closed.json b/packages/ap2/src/schemas/checkout-mandate-closed.json index 987fecbea..3440e6aa3 100644 --- a/packages/ap2/src/schemas/checkout-mandate-closed.json +++ b/packages/ap2/src/schemas/checkout-mandate-closed.json @@ -1,27 +1,42 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Closed Checkout Mandate Content", - "description": "The disclosed content of a Closed Checkout Mandate (mandate.checkout.1). checkout_jwt is the fully-revealed input to signing; it is replaced by an _sd digest (object-property disclosure) at signing time, leaving checkout_hash as the caller-visible binding value.", + "title": "Checkout Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Checkout action.", "type": "object", + "required": ["vct", "checkout_jwt", "checkout_hash"], "properties": { "vct": { - "const": "mandate.checkout.1" + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout'.", + "const": "mandate.checkout.1", + "default": "mandate.checkout.1" }, "checkout_jwt": { "type": "string", - "description": "The merchant-signed Checkout JWT, base64url-encoded. Present in the fully-revealed content passed to signClosedMandate; replaced by an _sd digest in the final disclosed/signed form." + "description": "base64url-encoded serialized merchant-signed JWT of the Checkout payload.", + "x-selectively-disclosable-field": true }, "checkout_hash": { "type": "string", - "description": "base64url hash of checkout_jwt." + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying this checkout. If this checkout mandate is presented as an sd-jwt and the _sd_alg field is present then the hash algorithm used MUST match the _sd_alg field. Otherwise, sha-256 MUST be used." }, "iat": { - "type": "integer" + "type": "integer", + "description": "The creation timestamp as a Unix epoch." }, "exp": { - "type": "integer" + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." } }, - "required": ["vct", "checkout_jwt", "checkout_hash"], - "additionalProperties": true + "name": "Checkout Mandate", + "$metadata": { + "version": 1, + "uris": { + "jsonSchema": "https://schema.truvera.io/CheckoutMandate-V1-1784310696727.json", + "jsonLdContext": "https://schema.truvera.io/CheckoutMandate-V1784310696727.json-ld" + } + }, + "additionalProperties": true, + "$id": "https://schema.truvera.io/CheckoutMandate-V1-1784310696727.json" } diff --git a/packages/ap2/src/schemas/checkout-mandate-open.json b/packages/ap2/src/schemas/checkout-mandate-open.json index 336575bca..3053d632d 100644 --- a/packages/ap2/src/schemas/checkout-mandate-open.json +++ b/packages/ap2/src/schemas/checkout-mandate-open.json @@ -1,49 +1,146 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Open Checkout Mandate Content", - "description": "The disclosed content of an Open Checkout Mandate (mandate.checkout.open.1). This describes the fully-revealed logical shape; array-element selective disclosure of constraints[].items[].acceptable_items and constraints[].allowed is applied separately at signing time.", + "title": "Open Checkout Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future checkout actions.", "type": "object", + "required": ["vct", "constraints", "cnf"], "properties": { "vct": { - "const": "mandate.checkout.open.1" + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout.open'.", + "const": "mandate.checkout.open.1", + "default": "mandate.checkout.open.1" }, "constraints": { "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", "items": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string" - } - } - } + "anyOf": [ + { "$ref": "#/$defs/allowed_merchants" }, + { "$ref": "#/$defs/line_items" } + ], + "type": "object" + }, + "contains": { "$ref": "#/$defs/line_items" } }, "cnf": { "type": "object", - "properties": { - "jwk": { - "type": "object", - "required": ["kty", "crv", "x", "y"], - "properties": { - "kty": { "const": "EC" }, - "crv": { "const": "P-256" }, - "x": { "type": "string" }, - "y": { "type": "string" } - } - } - }, - "required": ["jwk"] + "description": "Confirmation claim defined in RFC 7800 section 3.1. Used for key binding." }, "iat": { "type": "integer", - "description": "Creation timestamp as a Unix epoch." + "description": "The creation timestamp as a Unix epoch." }, "exp": { "type": "integer", - "description": "Expiration timestamp as a Unix epoch." + "description": "The expiration timestamp as a Unix epoch." } }, - "required": ["vct", "constraints", "cnf"], - "additionalProperties": true + "$defs": { + "allowed_merchants": { + "type": "object", + "description": "Defines the set of possible merchants for this Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.allowed_merchants", + "default": "checkout.allowed_merchants" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { "$ref": "#/$defs/merchant" }, + "x-selectively-disclosable-array": true + } + }, + "required": ["type", "allowed"] + }, + "line_items": { + "type": "object", + "description": "Defines the sets of line items that are to be present in the Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.line_items", + "default": "checkout.line_items" + }, + "items": { + "type": "array", + "description": "Array of line item requirements.", + "items": { "$ref": "#/$defs/line_item_requirements" }, + "minItems": 1 + } + }, + "required": ["type", "items"] + }, + "line_item_requirements": { + "type": "object", + "description": "Defines a line item that must be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the line item requirement." + }, + "acceptable_items": { + "type": "array", + "description": "Defines a set of line items that are acceptable for this line item requirement. One and only one must be present in the Checkout Mandate.", + "items": { "$ref": "#/$defs/item" }, + "x-selectively-disclosable-array": true + }, + "quantity": { + "type": "integer", + "description": "Required quantity of matching items.", + "exclusiveMinimum": 0 + } + }, + "required": ["id", "acceptable_items", "quantity"] + }, + "item": { + "type": "object", + "description": "Defines an item that may be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the line item. Will often be the SKU." + }, + "title": { + "type": "string", + "description": "Title of the item." + } + }, + "required": ["id", "title"] + }, + "merchant": { + "title": "Merchant", + "description": "Schema defining a Mechant object", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": ["id", "name"] + } + }, + "name": "Open Checkout Mandate", + "$metadata": { + "version": 1, + "uris": { + "jsonSchema": "https://schema.truvera.io/OpenCheckoutMandate-V1-1784315530026.json", + "jsonLdContext": "https://schema.truvera.io/OpenCheckoutMandate-V1784315530026.json-ld" + } + }, + "additionalProperties": true, + "$id": "https://schema.truvera.io/OpenCheckoutMandate-V1-1784315530026.json" } diff --git a/packages/ap2/src/schemas/payment-mandate-closed.json b/packages/ap2/src/schemas/payment-mandate-closed.json index 259f526b1..bbff42873 100644 --- a/packages/ap2/src/schemas/payment-mandate-closed.json +++ b/packages/ap2/src/schemas/payment-mandate-closed.json @@ -1,52 +1,143 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Closed Payment Mandate Content", - "description": "The disclosed content of a Closed Payment Mandate (mandate.payment.1).", + "title": "Payment Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Payment action.", "type": "object", + "required": ["vct", "transaction_id", "payee", "payment_amount", "payment_instrument"], "properties": { "vct": { - "const": "mandate.payment.1" + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment'.", + "const": "mandate.payment.1", + "default": "mandate.payment.1" }, "transaction_id": { "type": "string", - "description": "base64url hash of the associated Checkout Mandate's checkout_jwt (same value as that mandate's checkout_hash)." + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying the checkout associated with this. The hash algorithm used MUST be the same as the sd_hash field for this sd-jwt, or sha256 if absent." }, "payee": { - "type": "object", - "required": ["id", "name"], - "properties": { - "id": { "type": "string" }, - "name": { "type": "string" }, - "website": { "type": "string" } - } + "description": "The merchant receiving the payment.", + "$ref": "#/$defs/merchant", + "type": "object" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "#/$defs/pisp", + "type": "object" }, "payment_amount": { - "type": "object", - "required": ["amount", "currency"], - "properties": { - "amount": { "type": "number" }, - "currency": { "type": "string" } - } + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", + "$ref": "#/$defs/amount", + "type": "object" }, "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "#/$defs/payment_instrument", + "type": "object" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "merchant": { + "title": "Merchant", + "description": "Schema defining a Mechant object", "type": "object", - "required": ["id", "type"], "properties": { - "id": { "type": "string" }, - "type": { "type": "string" }, - "description": { "type": "string" } - } + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": ["id", "name"] }, "pisp": { - "type": "object" + "title": "PISP", + "description": "Schema defining a Payment Initiation Service Provider (PISP) object", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": ["legal_name", "brand_name", "domain_name"] }, - "execution_date": { - "type": "string" + "amount": { + "title": "Amount", + "description": "Schema defining an Amount and Current object", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": ["amount", "currency"] }, - "risk_data": { - "type": "object" + "payment_instrument": { + "title": "Payment Instrument", + "description": "Instrument used for payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": ["id", "type"] } }, - "required": ["vct", "transaction_id", "payee", "payment_amount", "payment_instrument"], - "additionalProperties": true + "name": "Payment Mandate", + "$metadata": { + "version": 1, + "uris": { + "jsonSchema": "https://schema.truvera.io/PaymentMandate-V1-1784315530495.json", + "jsonLdContext": "https://schema.truvera.io/PaymentMandate-V1784315530495.json-ld" + } + }, + "additionalProperties": true, + "$id": "https://schema.truvera.io/PaymentMandate-V1-1784315530495.json" } diff --git a/packages/ap2/src/schemas/payment-mandate-open.json b/packages/ap2/src/schemas/payment-mandate-open.json index f5618b196..2828e4ed2 100644 --- a/packages/ap2/src/schemas/payment-mandate-open.json +++ b/packages/ap2/src/schemas/payment-mandate-open.json @@ -1,47 +1,323 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "Open Payment Mandate Content", - "description": "The disclosed content of an Open Payment Mandate (mandate.payment.open.1). This describes the fully-revealed logical shape; array-element selective disclosure of constraints[].allowed (payment.allowed_payees) is applied separately at signing time.", + "title": "Open Payment Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future payment actions.", "type": "object", + "required": ["vct", "constraints", "cnf"], "properties": { "vct": { - "const": "mandate.payment.open.1" + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment.open'.", + "const": "mandate.payment.open.1", + "default": "mandate.payment.open.1" }, "constraints": { "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", "items": { - "type": "object", - "required": ["type"], - "properties": { - "type": { - "type": "string" - } + "oneOf": [ + { "$ref": "#/$defs/agent_recurrence" }, + { "$ref": "#/$defs/allowed_payees" }, + { "$ref": "#/$defs/allowed_payment_instruments" }, + { "$ref": "#/$defs/allowed_pisps" }, + { "$ref": "#/$defs/amount_range" }, + { "$ref": "#/$defs/budget" }, + { "$ref": "#/$defs/execution_date" }, + { "$ref": "#/$defs/payment_reference" } + ], + "type": "object" + }, + "contains": { "$ref": "#/$defs/payment_reference" } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim as defined in RFC 7800 section 3.1. Used for key binding." + }, + "payee": { + "description": "The merchant receiving the payment.", + "$ref": "#/$defs/merchant", + "type": "object" + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Pre-set by the user at time of mandate creation.", + "$ref": "#/$defs/amount", + "type": "object" + }, + "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "#/$defs/payment_instrument", + "type": "object" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "#/$defs/pisp", + "type": "object" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_payees": { + "type": "object", + "description": "Defines the set of possible payees.", + "required": ["type", "allowed"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payees", + "default": "payment.allowed_payees" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { "$ref": "#/$defs/merchant" }, + "x-selectively-disclosable-array": true } } }, - "cnf": { + "amount_range": { + "type": "object", + "description": "Defines the valid range for the payment amount", + "required": ["type", "currency", "max"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.amount_range", + "default": "payment.amount_range" + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 currency code." + }, + "max": { + "type": "integer", + "description": "Maximum allowed amount in minor (cents) unit of currency." + }, + "min": { + "type": "integer", + "description": "Minimal amount in minor (cents) unit of currency. If absent, there is no minimum." + } + } + }, + "payment_reference": { + "type": "object", + "description": "Constrains the payment to a specific checkout reference.", + "required": ["type", "conditional_transaction_id"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.reference", + "default": "payment.reference" + }, + "conditional_transaction_id": { + "type": "string", + "description": "Digest of the associated Open Checkout Mandate." + } + } + }, + "agent_recurrence": { + "type": "object", + "description": "Provides conditions for the agent to reuse this Payment Mandate multiple times.", + "required": ["type", "frequency"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.agent_recurrence", + "default": "payment.agent_recurrence" + }, + "frequency": { + "type": "string", + "description": "Frequency of allowed recurrences.", + "enum": ["ON_DEMAND", "DAILY", "WEEKLY", "BIWEEKLY", "MONTHLY", "QUARTERLY", "ANNUALLY"] + }, + "max_occurrences": { + "type": "integer", + "description": "Maximum number of allowed occurrences." + } + } + }, + "allowed_payment_instruments": { + "type": "object", + "description": "Defines the set of possible payment instruments for this Payment Mandate.", + "required": ["type", "allowed"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payment_instruments", + "default": "payment.allowed_payment_instruments" + }, + "allowed": { + "type": "array", + "description": "Array of allowed payment instruments.", + "items": { "$ref": "#/$defs/payment_instrument" }, + "x-selectively-disclosable-array": true + } + } + }, + "allowed_pisps": { + "type": "object", + "description": "Defines the set of Payment Initiation Service Providers (PISPs) authorized to facilitate the transaction.", + "required": ["type", "allowed"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_pisps", + "default": "payment.allowed_pisps" + }, + "allowed": { + "type": "array", + "description": "Array of allowed PISPs.", + "items": { "$ref": "#/$defs/pisp" } + } + } + }, + "budget": { + "type": "object", + "description": "Defines the maximum total amount that can be spent when using the payment.agent_recurrence constraint.", + "required": ["type", "max", "currency"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.budget", + "default": "payment.budget" + }, + "max": { + "type": "number", + "description": "Maximum amount for the budget." + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 defining the currency of the amount." + } + } + }, + "execution_date": { + "type": "object", + "description": "Defines the valid time window for the payment execution.", + "required": ["type"], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.execution_date", + "default": "payment.execution_date" + }, + "not_before": { + "type": "string", + "description": "Earliest valid execution date." + }, + "not_after": { + "type": "string", + "description": "Latest valid execution date." + } + } + }, + "merchant": { + "title": "Merchant", + "description": "Schema defining a Mechant object", "type": "object", "properties": { - "jwk": { - "type": "object", - "required": ["kty", "crv", "x", "y"], - "properties": { - "kty": { "const": "EC" }, - "crv": { "const": "P-256" }, - "x": { "type": "string" }, - "y": { "type": "string" } - } + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." } }, - "required": ["jwk"] + "required": ["id", "name"] }, - "iat": { - "type": "integer" + "amount": { + "title": "Amount", + "description": "Schema defining an Amount and Current object", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": ["amount", "currency"] }, - "exp": { - "type": "integer" + "payment_instrument": { + "title": "Payment Instrument", + "description": "Instrument used for payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": ["id", "type"] + }, + "pisp": { + "title": "PISP", + "description": "Schema defining a Payment Initiation Service Provider (PISP) object", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": ["legal_name", "brand_name", "domain_name"] } }, - "required": ["vct", "constraints", "cnf"], - "additionalProperties": true + "name": "Open Payment Mandate", + "$metadata": { + "version": 1, + "uris": { + "jsonSchema": "https://schema.truvera.io/OpenPaymentMandate-V1-1784315530952.json", + "jsonLdContext": "https://schema.truvera.io/OpenPaymentMandate-V1784315530952.json-ld" + } + }, + "additionalProperties": true, + "$id": "https://schema.truvera.io/OpenPaymentMandate-V1-1784315530952.json" } From de41fd12b0ebd4495a5112967e81c833c7c08697 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 11:59:14 -0400 Subject: [PATCH 6/9] test(ap2): update mandate test fixtures for stricter published schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The round-trip test's Open Payment Mandate constraints now include a payment.reference entry, and the two negative-path tests' Open Checkout Mandates now include a minimal checkout.line_items constraint — both now schema-required (see previous commit), where the tests previously used an empty/incomplete constraints array that only happened to pass under the old, looser hand-authored schema. Co-Authored-By: Claude Sonnet 5 --- packages/ap2/tests/mandates.test.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index bcc686196..ff9b289d5 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -5,6 +5,7 @@ import { parseSdJwtPresentation, decodeJwtPayload, secp256r1PublicKeyToJwk, + computeSdHash, } from '@docknetwork/crypto-utils/vc'; import { @@ -29,6 +30,12 @@ import { const VCT_CHECKOUT_OPEN = 'mandate.checkout.open.1'; const VCT_CHECKOUT_CLOSED = 'mandate.checkout.1'; const SAMPLE_CHECKOUT_JWT = 'eyJhbGciOiJFUzI1NiJ9.eyJvcmRlcl9pZCI6Im9yZGVyLTEifQ.sig'; +// The published Truvera schema requires an Open Checkout Mandate's +// constraints to contain at least one checkout.line_items entry. +const MINIMAL_LINE_ITEMS_CONSTRAINT = { + type: 'checkout.line_items', + items: [{ id: 'line_1', acceptable_items: [{ id: 'SKU-1', title: 'Widget' }], quantity: 1 }], +}; const SPEC_OPEN_CHECKOUT_TOKEN = 'eyJhbGciOiAiRVMyNTYiLCAidHlwIjogImV4YW1wbGUrc2Qtand0IiwgImtpZCI6ICJhZ2VudC1wcm92aWRlci1rZXktMSJ9.eyJkZWxlZ2F0ZV9wYXlsb2FkIjogW3siLi4uIjogIlF0WFRKdFdxZzk5OUNtVVdHakhGVFdNa1JQZ3VEZmVLM3dHU2FJbmQtZHcifV0sICJfc2RfYWxnIjogInNoYS0yNTYifQ.HvCGk7ye_c0LN2-NFG13wfyu3LA--rckTPGm36ugO2aRvsded7ngw1py8W3JF7wBpoQnsKr17tNTF3zLeYcoWA~WyI0bjNMXy0zX0ZtMkdneUZBRjhDdF9nIiwgeyJpZCI6ICJzdXBlcnNob2VfbGltaXRlZF9lZGl0aW9uX2dvbGRfc25lYWtlcl93b21lbnNfOV8wIiwgInRpdGxlIjogIlN1cGVyU2hvZSBMaW1pdGVkIEVkaXRpb24gR29sZCJ9XQ~WyIyelBMNnZxTEJnMldZQWRiVzktMWxRIiwgeyJpZCI6ICJtZXJjaGFudF8xIiwgIm5hbWUiOiAiRGVtbyBNZXJjaGFudCIsICJ3ZWJzaXRlIjogImh0dHBzOi8vZGVtby1tZXJjaGFudC5leGFtcGxlIn1d~WyJsYUFvV0tOUnVHbndSRWpKV1lKN3BnIiwgeyJ2Y3QiOiAibWFuZGF0ZS5jaGVja291dC5vcGVuLjEiLCAiY29uc3RyYWludHMiOiBbeyJ0eXBlIjogImNoZWNrb3V0LmxpbmVfaXRlbXMiLCAiaXRlbXMiOiBbeyJpZCI6ICJsaW5lXzEiLCAiYWNjZXB0YWJsZV9pdGVtcyI6IFt7Ii4uLiI6ICJ5M2FvY0FEMnJoWXBKUU9VTU4wMTZmYURGR2tUQkdFRFZsMVIxVFJIZGJ3In1dLCAicXVhbnRpdHkiOiAxfV19LCB7InR5cGUiOiAiY2hlY2tvdXQuYWxsb3dlZF9tZXJjaGFudHMiLCAiYWxsb3dlZCI6IFt7Ii4uLiI6ICJhNVVNQWR4Q2tfTVJheXlWZFJocElBWjBaaGpWTEVxMWcyQld5cndLVXdnIn1dfV0sICJjbmYiOiB7Imp3ayI6IHsiY3J2IjogIlAtMjU2IiwgImt0eSI6ICJFQyIsICJ4IjogIlFwU3l4UFFIeTM4eGNreXZEcjU0Z1ozVDQyemo5aUx0VjRrb3liNVUyN2MiLCAieSI6ICIzN0hMZDdKSmlueGpKSW44SjdIaWpzc29lY0JsZmhkVy1nVUw3ZmVJOWx3In19LCAiaWF0IjogMTc3NzM0MjM1NywgImV4cCI6IDE3NzczNDU5NTd9XQ~'; @@ -131,6 +138,7 @@ describe('AP2 mandates: round trip', () => { expect(checkoutResult.checkoutJwt).toBe(checkoutJwt); expect(checkoutResult.sdHashVerified).toBe(true); + const conditionalTransactionId = computeSdHash(parseSdJwtPresentation(openCheckoutPresentation)); const openPaymentContent = buildOpenPaymentMandate({ vct: 'mandate.payment.open.1', constraints: [ @@ -138,6 +146,7 @@ describe('AP2 mandates: round trip', () => { type: 'payment.amount_range', currency: 'USD', min: 0, max: 20000, }, { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.reference', conditional_transaction_id: conditionalTransactionId }, ], cnf, iat: 1000, @@ -178,7 +187,7 @@ describe('AP2 mandates: round trip', () => { const openCheckoutPresentation = await signOpenCheckoutMandate( buildOpenCheckoutMandate({ vct: VCT_CHECKOUT_OPEN, - constraints: [], + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], cnf, }), { signer: userKeypair }, @@ -214,7 +223,7 @@ describe('AP2 mandates: round trip', () => { const openCheckoutPresentation = await signOpenCheckoutMandate( buildOpenCheckoutMandate({ vct: VCT_CHECKOUT_OPEN, - constraints: [], + constraints: [MINIMAL_LINE_ITEMS_CONSTRAINT], cnf, }), { signer: userKeypair }, From 102a7e20d167fb825ace6c9b4a637b4737f8fdbb Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 11:59:24 -0400 Subject: [PATCH 7/9] docs(ap2): note published schema source and required constraints Points readers at the canonical schema.truvera.io documents (referenced in each schema file's $id) and calls out the two schema-required constraints and the full set of supported Open Payment Mandate constraint types. Co-Authored-By: Claude Sonnet 5 --- packages/ap2/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/ap2/README.md b/packages/ap2/README.md index b32a1f1ff..b1c5760d7 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -148,6 +148,18 @@ pattern, binding to a Checkout Mandate via `transaction_id` (a hash of `checkout_jwt`, verified against a `checkoutJwt` passed to `verifyClosedPaymentMandate`). +`build*` validates content against Truvera's published JSON Schemas +(`src/schemas/*.json`, matching the canonical documents at +`schema.truvera.io` — see each file's `$id`). Two constraints are +schema-required, not just conventional: an Open Checkout Mandate's +`constraints` must contain at least one `checkout.line_items` entry, and an +Open Payment Mandate's `constraints` must contain a `payment.reference` entry +(with a `conditional_transaction_id` — see the Open Payment Mandate schema +for the full set of supported constraint types, including +`payment.allowed_payees`, `payment.allowed_payment_instruments`, +`payment.allowed_pisps`, `payment.amount_range`, `payment.budget`, +`payment.agent_recurrence`, and `payment.execution_date`). + The `signer` for both signing functions uses the same BYOK `signer.sign(data)` contract as `signReceipt` — bridge it to a wallet-held key without ever exposing the private key to this package. From a92806e2fd1425a752e3848e1ae758d974db1082 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Wed, 22 Jul 2026 15:36:50 -0400 Subject: [PATCH 8/9] move ap2 schema import from the web repo to here --- packages/ap2/CHANGELOG.md | 24 ++ packages/ap2/README.md | 18 +- packages/ap2/package.json | 3 +- packages/ap2/scripts/generate-schemas.mjs | 98 +++++ packages/ap2/src/index.js | 6 + .../src/schemas/checkout-mandate-closed.json | 18 +- .../src/schemas/checkout-mandate-open.json | 105 +++--- .../src/schemas/payment-mandate-closed.json | 107 +++--- .../ap2/src/schemas/payment-mandate-open.json | 335 +++++++++++------- packages/ap2/src/utils.js | 9 + .../checkout_mandate.json | 38 ++ .../checkout_receipt.json | 68 ++++ .../open_checkout_mandate.json | 145 ++++++++ .../open_payment_mandate.json | 294 +++++++++++++++ .../upstream-ap2-schemas/payment_mandate.json | 58 +++ .../upstream-ap2-schemas/payment_receipt.json | 78 ++++ .../upstream-ap2-schemas/types/amount.json | 21 ++ .../upstream-ap2-schemas/types/merchant.json | 25 ++ .../types/payment_instrument.json | 25 ++ .../ap2/upstream-ap2-schemas/types/pisp.json | 26 ++ .../types/receipt_status.json | 11 + 21 files changed, 1269 insertions(+), 243 deletions(-) create mode 100644 packages/ap2/scripts/generate-schemas.mjs create mode 100644 packages/ap2/upstream-ap2-schemas/checkout_mandate.json create mode 100644 packages/ap2/upstream-ap2-schemas/checkout_receipt.json create mode 100644 packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json create mode 100644 packages/ap2/upstream-ap2-schemas/open_payment_mandate.json create mode 100644 packages/ap2/upstream-ap2-schemas/payment_mandate.json create mode 100644 packages/ap2/upstream-ap2-schemas/payment_receipt.json create mode 100644 packages/ap2/upstream-ap2-schemas/types/amount.json create mode 100644 packages/ap2/upstream-ap2-schemas/types/merchant.json create mode 100644 packages/ap2/upstream-ap2-schemas/types/payment_instrument.json create mode 100644 packages/ap2/upstream-ap2-schemas/types/pisp.json create mode 100644 packages/ap2/upstream-ap2-schemas/types/receipt_status.json diff --git a/packages/ap2/CHANGELOG.md b/packages/ap2/CHANGELOG.md index 2de0c18d0..e36b226da 100644 --- a/packages/ap2/CHANGELOG.md +++ b/packages/ap2/CHANGELOG.md @@ -1,5 +1,29 @@ # @docknetwork/ap2 +## 0.3.0 + +### Minor Changes + +- 61e1eb5: Add AP2 v0.2 mandate support: build/sign/verify functions for Open and + Closed Checkout Mandates and Payment Mandates + (`buildOpenCheckoutMandate`, `signOpenCheckoutMandate`, + `buildClosedCheckoutMandate`, `signClosedCheckoutMandate`, + `verifyClosedCheckoutMandate`, `buildOpenPaymentMandate`, + `signOpenPaymentMandate`, `buildClosedPaymentMandate`, + `signClosedPaymentMandate`, `verifyClosedPaymentMandate`, + `computeCheckoutHash`, `computeDisclosureDigest`). Mandate verification now + lives in this package instead of requiring an external dependency. + + The four mandate JSON Schemas (`src/schemas/{checkout,payment}-mandate- +{open,closed}.json`) are now generated from a pinned upstream AP2 mirror + (`upstream-ap2-schemas/`) via `npm run generate-schemas`, rather than + hand-maintained. All six packaged JSON Schemas (the four mandate schemas + plus the existing checkout/payment receipt schemas) are now exported as + named exports (`checkoutMandateOpenSchema`, `checkoutMandateClosedSchema`, + `paymentMandateOpenSchema`, `paymentMandateClosedSchema`, + `checkoutReceiptSchema`, `paymentReceiptSchema`) for consumers that need + the raw schema content. + ## 0.2.1 ### Patch Changes diff --git a/packages/ap2/README.md b/packages/ap2/README.md index b1c5760d7..13631182e 100644 --- a/packages/ap2/README.md +++ b/packages/ap2/README.md @@ -65,7 +65,11 @@ style. Successful results may also include `referenceVerified` and ## Payloads and signing `buildCheckoutReceipt` and `buildPaymentReceipt` validate against the packaged -AP2 JSON Schemas, clone the payload, and preserve extension properties. +AP2 JSON Schemas, clone the payload, and preserve extension properties. These +schemas are also available as named exports (`checkoutReceiptSchema`, +`paymentReceiptSchema`) for consumers that want the raw JSON Schema — e.g. for +form generation or documentation — without re-validating through this +package. `signReceipt` is the BYOK entry point. Its `signer.sign(data)` function may be synchronous or asynchronous and must return an ES256 signature in 64-byte JOSE @@ -148,10 +152,14 @@ pattern, binding to a Checkout Mandate via `transaction_id` (a hash of `checkout_jwt`, verified against a `checkoutJwt` passed to `verifyClosedPaymentMandate`). -`build*` validates content against Truvera's published JSON Schemas -(`src/schemas/*.json`, matching the canonical documents at -`schema.truvera.io` — see each file's `$id`). Two constraints are -schema-required, not just conventional: an Open Checkout Mandate's +`build*` validates content against this package's JSON Schemas +(`src/schemas/*.json`), generated from the real upstream AP2 source +(`upstream-ap2-schemas/`, a pinned mirror of +[google-agentic-commerce/AP2](https://github.com/google-agentic-commerce/AP2)) +via `npm run generate-schemas` — re-run that script and rebuild after +re-vendoring a newer upstream tag; don't hand-edit `src/schemas/*.json` +directly. Two constraints are schema-required, not just conventional: an Open +Checkout Mandate's `constraints` must contain at least one `checkout.line_items` entry, and an Open Payment Mandate's `constraints` must contain a `payment.reference` entry (with a `conditional_transaction_id` — see the Open Payment Mandate schema diff --git a/packages/ap2/package.json b/packages/ap2/package.json index d974d516f..6907fd141 100644 --- a/packages/ap2/package.json +++ b/packages/ap2/package.json @@ -1,6 +1,6 @@ { "name": "@docknetwork/ap2", - "version": "0.2.1", + "version": "0.3.0", "description": "Basic AP2 support", "license": "MIT", "type": "module", @@ -52,6 +52,7 @@ }, "scripts": { "build": "rollup -c", + "generate-schemas": "node scripts/generate-schemas.mjs", "example:receipt": "yarn build && node examples/issue-and-verify-receipt.mjs", "test": "jest --runInBand", "lint": "eslint \"src/**/*.js\"", diff --git a/packages/ap2/scripts/generate-schemas.mjs b/packages/ap2/scripts/generate-schemas.mjs new file mode 100644 index 000000000..6249f7912 --- /dev/null +++ b/packages/ap2/scripts/generate-schemas.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * Regenerates this package's mandate JSON Schemas + * (src/schemas/{checkout,payment}-mandate-{open,closed}.json) from the vendored + * upstream AP2 mirror in ./upstream-ap2-schemas. + * + * Source of truth: https://github.com/google-agentic-commerce/AP2/tree/v0.2.0/code/sdk/schemas/ap2 + * (mirrored, unmodified, in ./upstream-ap2-schemas - re-vendor from a newer tag and + * re-run this script when upstream AP2 changes; this repo owns no schema content of + * its own beyond that mirror). + * + * The only transform applied is inlining AP2's cross-file "types/*.json" refs + * (Merchant, Amount, PaymentInstrument, PISP) into local, same-document $defs, so + * each output file is a self-contained JSON Schema document that ajv can compile + * standalone (matching the $defs shape these schemas already use for their own + * inline constraint types). This is a general "make it portable" step, not a + * Truvera-specific one - schema-hosting/consumer-specific transforms (e.g. + * Truvera's own backfilled `type` siblings and empty `properties: {}` placeholders) + * are intentionally NOT applied here; those stay in whichever downstream consumer + * needs them. + * + * Usage: + * node scripts/generate-schemas.mjs + */ + +import { readFileSync, writeFileSync } from 'fs'; +import { fileURLToPath } from 'url'; +import path from 'path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const UPSTREAM_DIR = path.join(__dirname, '..', 'upstream-ap2-schemas'); +const OUTPUT_DIR = path.join(__dirname, '..', 'src', 'schemas'); + +// AP2 "types/*.json" refs to object-shaped types: fully inlined (type+properties+ +// required), no $ref left behind, so the output schema is self-contained. +const OBJECT_TYPE_REFS = { + 'types/amount.json': 'amount', + 'types/merchant.json': 'merchant', + 'types/payment_instrument.json': 'payment_instrument', + 'types/pisp.json': 'pisp', +}; + +// Upstream file name -> output file name. Only the four mandate schemas this +// package uses; upstream's receipt schemas aren't part of this generator. +const SCHEMA_FILES = { + 'open_checkout_mandate.json': 'checkout-mandate-open.json', + 'checkout_mandate.json': 'checkout-mandate-closed.json', + 'open_payment_mandate.json': 'payment-mandate-open.json', + 'payment_mandate.json': 'payment-mandate-closed.json', +}; + +function readJSON(dir, relPath) { + return JSON.parse(readFileSync(path.join(dir, relPath), 'utf8')); +} + +function loadTypeDef(ref) { + const body = readJSON(UPSTREAM_DIR, ref); + delete body.$schema; + delete body.$id; + return body; +} + +// Walks the schema, replacing any $ref to a known object-type file with its type/ +// properties/required inlined directly - the referencing node keeps its own +// `description` if it had one. +function inlineObjectRefs(node) { + if (Array.isArray(node)) { + node.forEach(inlineObjectRefs); + return; + } + if (node && typeof node === 'object') { + if (typeof node.$ref === 'string' && OBJECT_TYPE_REFS[node.$ref]) { + const typeBody = loadTypeDef(node.$ref); + delete node.$ref; + node.type = typeBody.type; + node.properties = typeBody.properties; + node.required = typeBody.required; + if (!node.description && typeBody.description) node.description = typeBody.description; + } + Object.values(node).forEach(inlineObjectRefs); + } +} + +function generate(upstreamFileName, outputFileName) { + const schema = readJSON(UPSTREAM_DIR, upstreamFileName); + schema.$schema = 'http://json-schema.org/draft-07/schema#'; + delete schema.$id; + + inlineObjectRefs(schema); + + const outPath = path.join(OUTPUT_DIR, outputFileName); + writeFileSync(outPath, `${JSON.stringify(schema, null, 2)}\n`); + console.log(`Generated ${outPath}`); +} + +for (const [upstreamFileName, outputFileName] of Object.entries(SCHEMA_FILES)) { + generate(upstreamFileName, outputFileName); +} diff --git a/packages/ap2/src/index.js b/packages/ap2/src/index.js index 3b1efcfba..8f173104f 100644 --- a/packages/ap2/src/index.js +++ b/packages/ap2/src/index.js @@ -7,4 +7,10 @@ export { validateReceipt, validateReceiptTime, validateMandateContent, + checkoutReceiptSchema, + paymentReceiptSchema, + checkoutMandateOpenSchema, + checkoutMandateClosedSchema, + paymentMandateOpenSchema, + paymentMandateClosedSchema, } from './utils'; diff --git a/packages/ap2/src/schemas/checkout-mandate-closed.json b/packages/ap2/src/schemas/checkout-mandate-closed.json index 3440e6aa3..0f78772b6 100644 --- a/packages/ap2/src/schemas/checkout-mandate-closed.json +++ b/packages/ap2/src/schemas/checkout-mandate-closed.json @@ -3,7 +3,11 @@ "title": "Checkout Mandate", "description": "Agreement from a User or an Agent to authorize a particular Checkout action.", "type": "object", - "required": ["vct", "checkout_jwt", "checkout_hash"], + "required": [ + "vct", + "checkout_jwt", + "checkout_hash" + ], "properties": { "vct": { "type": "string", @@ -28,15 +32,5 @@ "type": "integer", "description": "The expiration timestamp as a Unix epoch." } - }, - "name": "Checkout Mandate", - "$metadata": { - "version": 1, - "uris": { - "jsonSchema": "https://schema.truvera.io/CheckoutMandate-V1-1784310696727.json", - "jsonLdContext": "https://schema.truvera.io/CheckoutMandate-V1784310696727.json-ld" - } - }, - "additionalProperties": true, - "$id": "https://schema.truvera.io/CheckoutMandate-V1-1784310696727.json" + } } diff --git a/packages/ap2/src/schemas/checkout-mandate-open.json b/packages/ap2/src/schemas/checkout-mandate-open.json index 3053d632d..5b08f3c91 100644 --- a/packages/ap2/src/schemas/checkout-mandate-open.json +++ b/packages/ap2/src/schemas/checkout-mandate-open.json @@ -3,7 +3,11 @@ "title": "Open Checkout Mandate", "description": "Agreement between a user and an agent (or chain of agents) to authorize future checkout actions.", "type": "object", - "required": ["vct", "constraints", "cnf"], + "required": [ + "vct", + "constraints", + "cnf" + ], "properties": { "vct": { "type": "string", @@ -16,12 +20,17 @@ "description": "Array of constraints that the future checkout action must abide by.", "items": { "anyOf": [ - { "$ref": "#/$defs/allowed_merchants" }, - { "$ref": "#/$defs/line_items" } - ], - "type": "object" + { + "$ref": "#/$defs/allowed_merchants" + }, + { + "$ref": "#/$defs/line_items" + } + ] }, - "contains": { "$ref": "#/$defs/line_items" } + "contains": { + "$ref": "#/$defs/line_items" + } }, "cnf": { "type": "object", @@ -50,11 +59,35 @@ "allowed": { "type": "array", "description": "Array of allowed Merchant objects.", - "items": { "$ref": "#/$defs/merchant" }, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ], + "description": "Schema defining a Mechant object" + }, "x-selectively-disclosable-array": true } }, - "required": ["type", "allowed"] + "required": [ + "type", + "allowed" + ] }, "line_items": { "type": "object", @@ -69,11 +102,16 @@ "items": { "type": "array", "description": "Array of line item requirements.", - "items": { "$ref": "#/$defs/line_item_requirements" }, + "items": { + "$ref": "#/$defs/line_item_requirements" + }, "minItems": 1 } }, - "required": ["type", "items"] + "required": [ + "type", + "items" + ] }, "line_item_requirements": { "type": "object", @@ -86,7 +124,9 @@ "acceptable_items": { "type": "array", "description": "Defines a set of line items that are acceptable for this line item requirement. One and only one must be present in the Checkout Mandate.", - "items": { "$ref": "#/$defs/item" }, + "items": { + "$ref": "#/$defs/item" + }, "x-selectively-disclosable-array": true }, "quantity": { @@ -95,7 +135,11 @@ "exclusiveMinimum": 0 } }, - "required": ["id", "acceptable_items", "quantity"] + "required": [ + "id", + "acceptable_items", + "quantity" + ] }, "item": { "type": "object", @@ -110,37 +154,10 @@ "description": "Title of the item." } }, - "required": ["id", "title"] - }, - "merchant": { - "title": "Merchant", - "description": "Schema defining a Mechant object", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the merchant." - }, - "name": { - "type": "string", - "description": "Human-readable name of the merchant." - }, - "website": { - "type": "string", - "description": "Website belonging to the merchant." - } - }, - "required": ["id", "name"] + "required": [ + "id", + "title" + ] } - }, - "name": "Open Checkout Mandate", - "$metadata": { - "version": 1, - "uris": { - "jsonSchema": "https://schema.truvera.io/OpenCheckoutMandate-V1-1784315530026.json", - "jsonLdContext": "https://schema.truvera.io/OpenCheckoutMandate-V1784315530026.json-ld" - } - }, - "additionalProperties": true, - "$id": "https://schema.truvera.io/OpenCheckoutMandate-V1-1784315530026.json" + } } diff --git a/packages/ap2/src/schemas/payment-mandate-closed.json b/packages/ap2/src/schemas/payment-mandate-closed.json index bbff42873..5c747cfe1 100644 --- a/packages/ap2/src/schemas/payment-mandate-closed.json +++ b/packages/ap2/src/schemas/payment-mandate-closed.json @@ -3,7 +3,13 @@ "title": "Payment Mandate", "description": "Agreement from a User or an Agent to authorize a particular Payment action.", "type": "object", - "required": ["vct", "transaction_id", "payee", "payment_amount", "payment_instrument"], + "required": [ + "vct", + "transaction_id", + "payee", + "payment_amount", + "payment_instrument" + ], "properties": { "vct": { "type": "string", @@ -17,45 +23,6 @@ }, "payee": { "description": "The merchant receiving the payment.", - "$ref": "#/$defs/merchant", - "type": "object" - }, - "pisp": { - "description": "The Payment Initiation Service Provider.", - "$ref": "#/$defs/pisp", - "type": "object" - }, - "payment_amount": { - "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", - "$ref": "#/$defs/amount", - "type": "object" - }, - "payment_instrument": { - "description": "The payment instrument used.", - "$ref": "#/$defs/payment_instrument", - "type": "object" - }, - "execution_date": { - "type": "string", - "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." - }, - "risk_data": { - "type": "object", - "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." - }, - "iat": { - "type": "integer", - "description": "The creation timestamp as a Unix epoch." - }, - "exp": { - "type": "integer", - "description": "The expiration timestamp as a Unix epoch." - } - }, - "$defs": { - "merchant": { - "title": "Merchant", - "description": "Schema defining a Mechant object", "type": "object", "properties": { "id": { @@ -71,11 +38,13 @@ "description": "Website belonging to the merchant." } }, - "required": ["id", "name"] + "required": [ + "id", + "name" + ] }, "pisp": { - "title": "PISP", - "description": "Schema defining a Payment Initiation Service Provider (PISP) object", + "description": "The Payment Initiation Service Provider.", "type": "object", "properties": { "legal_name": { @@ -91,11 +60,14 @@ "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." } }, - "required": ["legal_name", "brand_name", "domain_name"] + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] }, - "amount": { - "title": "Amount", - "description": "Schema defining an Amount and Current object", + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", "type": "object", "properties": { "amount": { @@ -107,11 +79,13 @@ "description": "ISO-4217 3-letter alphabetic currency code of the payment." } }, - "required": ["amount", "currency"] + "required": [ + "amount", + "currency" + ] }, "payment_instrument": { - "title": "Payment Instrument", - "description": "Instrument used for payment.", + "description": "The payment instrument used.", "type": "object", "properties": { "id": { @@ -127,17 +101,26 @@ "description": "Description of the instrument to be displayed to the user for informational purposes" } }, - "required": ["id", "type"] - } - }, - "name": "Payment Mandate", - "$metadata": { - "version": 1, - "uris": { - "jsonSchema": "https://schema.truvera.io/PaymentMandate-V1-1784315530495.json", - "jsonLdContext": "https://schema.truvera.io/PaymentMandate-V1784315530495.json-ld" + "required": [ + "id", + "type" + ] + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." } - }, - "additionalProperties": true, - "$id": "https://schema.truvera.io/PaymentMandate-V1-1784315530495.json" + } } diff --git a/packages/ap2/src/schemas/payment-mandate-open.json b/packages/ap2/src/schemas/payment-mandate-open.json index 2828e4ed2..82240eb74 100644 --- a/packages/ap2/src/schemas/payment-mandate-open.json +++ b/packages/ap2/src/schemas/payment-mandate-open.json @@ -3,7 +3,11 @@ "title": "Open Payment Mandate", "description": "Agreement between a user and an agent (or chain of agents) to authorize future payment actions.", "type": "object", - "required": ["vct", "constraints", "cnf"], + "required": [ + "vct", + "constraints", + "cnf" + ], "properties": { "vct": { "type": "string", @@ -16,18 +20,35 @@ "description": "Array of constraints that the future checkout action must abide by.", "items": { "oneOf": [ - { "$ref": "#/$defs/agent_recurrence" }, - { "$ref": "#/$defs/allowed_payees" }, - { "$ref": "#/$defs/allowed_payment_instruments" }, - { "$ref": "#/$defs/allowed_pisps" }, - { "$ref": "#/$defs/amount_range" }, - { "$ref": "#/$defs/budget" }, - { "$ref": "#/$defs/execution_date" }, - { "$ref": "#/$defs/payment_reference" } - ], - "type": "object" + { + "$ref": "#/$defs/agent_recurrence" + }, + { + "$ref": "#/$defs/allowed_payees" + }, + { + "$ref": "#/$defs/allowed_payment_instruments" + }, + { + "$ref": "#/$defs/allowed_pisps" + }, + { + "$ref": "#/$defs/amount_range" + }, + { + "$ref": "#/$defs/budget" + }, + { + "$ref": "#/$defs/execution_date" + }, + { + "$ref": "#/$defs/payment_reference" + } + ] }, - "contains": { "$ref": "#/$defs/payment_reference" } + "contains": { + "$ref": "#/$defs/payment_reference" + } }, "cnf": { "type": "object", @@ -35,23 +56,88 @@ }, "payee": { "description": "The merchant receiving the payment.", - "$ref": "#/$defs/merchant", - "type": "object" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ] }, "payment_amount": { "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Pre-set by the user at time of mandate creation.", - "$ref": "#/$defs/amount", - "type": "object" + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": [ + "amount", + "currency" + ] }, "payment_instrument": { "description": "The payment instrument used.", - "$ref": "#/$defs/payment_instrument", - "type": "object" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ] }, "pisp": { "description": "The Payment Initiation Service Provider.", - "$ref": "#/$defs/pisp", - "type": "object" + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] }, "execution_date": { "type": "string", @@ -74,7 +160,10 @@ "allowed_payees": { "type": "object", "description": "Defines the set of possible payees.", - "required": ["type", "allowed"], + "required": [ + "type", + "allowed" + ], "properties": { "type": { "type": "string", @@ -85,7 +174,28 @@ "allowed": { "type": "array", "description": "Array of allowed Merchant objects.", - "items": { "$ref": "#/$defs/merchant" }, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ], + "description": "Schema defining a Mechant object" + }, "x-selectively-disclosable-array": true } } @@ -93,7 +203,11 @@ "amount_range": { "type": "object", "description": "Defines the valid range for the payment amount", - "required": ["type", "currency", "max"], + "required": [ + "type", + "currency", + "max" + ], "properties": { "type": { "type": "string", @@ -118,7 +232,10 @@ "payment_reference": { "type": "object", "description": "Constrains the payment to a specific checkout reference.", - "required": ["type", "conditional_transaction_id"], + "required": [ + "type", + "conditional_transaction_id" + ], "properties": { "type": { "type": "string", @@ -135,7 +252,10 @@ "agent_recurrence": { "type": "object", "description": "Provides conditions for the agent to reuse this Payment Mandate multiple times.", - "required": ["type", "frequency"], + "required": [ + "type", + "frequency" + ], "properties": { "type": { "type": "string", @@ -146,7 +266,15 @@ "frequency": { "type": "string", "description": "Frequency of allowed recurrences.", - "enum": ["ON_DEMAND", "DAILY", "WEEKLY", "BIWEEKLY", "MONTHLY", "QUARTERLY", "ANNUALLY"] + "enum": [ + "ON_DEMAND", + "DAILY", + "WEEKLY", + "BIWEEKLY", + "MONTHLY", + "QUARTERLY", + "ANNUALLY" + ] }, "max_occurrences": { "type": "integer", @@ -157,7 +285,10 @@ "allowed_payment_instruments": { "type": "object", "description": "Defines the set of possible payment instruments for this Payment Mandate.", - "required": ["type", "allowed"], + "required": [ + "type", + "allowed" + ], "properties": { "type": { "type": "string", @@ -168,7 +299,28 @@ "allowed": { "type": "array", "description": "Array of allowed payment instruments.", - "items": { "$ref": "#/$defs/payment_instrument" }, + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ], + "description": "Instrument used for payment." + }, "x-selectively-disclosable-array": true } } @@ -176,7 +328,10 @@ "allowed_pisps": { "type": "object", "description": "Defines the set of Payment Initiation Service Providers (PISPs) authorized to facilitate the transaction.", - "required": ["type", "allowed"], + "required": [ + "type", + "allowed" + ], "properties": { "type": { "type": "string", @@ -187,14 +342,40 @@ "allowed": { "type": "array", "description": "Array of allowed PISPs.", - "items": { "$ref": "#/$defs/pisp" } + "items": { + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ], + "description": "Schema defining a Payment Initiation Service Provider (PISP) object" + } } } }, "budget": { "type": "object", "description": "Defines the maximum total amount that can be spent when using the payment.agent_recurrence constraint.", - "required": ["type", "max", "currency"], + "required": [ + "type", + "max", + "currency" + ], "properties": { "type": { "type": "string", @@ -215,7 +396,9 @@ "execution_date": { "type": "object", "description": "Defines the valid time window for the payment execution.", - "required": ["type"], + "required": [ + "type" + ], "properties": { "type": { "type": "string", @@ -232,92 +415,6 @@ "description": "Latest valid execution date." } } - }, - "merchant": { - "title": "Merchant", - "description": "Schema defining a Mechant object", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique identifier for the merchant." - }, - "name": { - "type": "string", - "description": "Human-readable name of the merchant." - }, - "website": { - "type": "string", - "description": "Website belonging to the merchant." - } - }, - "required": ["id", "name"] - }, - "amount": { - "title": "Amount", - "description": "Schema defining an Amount and Current object", - "type": "object", - "properties": { - "amount": { - "type": "integer", - "description": "Amount in minor units, according to the ISO-4217 spec." - }, - "currency": { - "type": "string", - "description": "ISO-4217 3-letter alphabetic currency code of the payment." - } - }, - "required": ["amount", "currency"] - }, - "payment_instrument": { - "title": "Payment Instrument", - "description": "Instrument used for payment.", - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "unique identifier for this instrument" - }, - "type": { - "type": "string", - "description": "unique string identifying this category of instrument" - }, - "description": { - "type": "string", - "description": "Description of the instrument to be displayed to the user for informational purposes" - } - }, - "required": ["id", "type"] - }, - "pisp": { - "title": "PISP", - "description": "Schema defining a Payment Initiation Service Provider (PISP) object", - "type": "object", - "properties": { - "legal_name": { - "type": "string", - "description": "Legal name of the PISP." - }, - "brand_name": { - "type": "string", - "description": "Brand name of the PISP." - }, - "domain_name": { - "type": "string", - "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." - } - }, - "required": ["legal_name", "brand_name", "domain_name"] } - }, - "name": "Open Payment Mandate", - "$metadata": { - "version": 1, - "uris": { - "jsonSchema": "https://schema.truvera.io/OpenPaymentMandate-V1-1784315530952.json", - "jsonLdContext": "https://schema.truvera.io/OpenPaymentMandate-V1784315530952.json-ld" - } - }, - "additionalProperties": true, - "$id": "https://schema.truvera.io/OpenPaymentMandate-V1-1784315530952.json" + } } diff --git a/packages/ap2/src/utils.js b/packages/ap2/src/utils.js index d0fe604e4..5c1a7e8b4 100644 --- a/packages/ap2/src/utils.js +++ b/packages/ap2/src/utils.js @@ -11,6 +11,15 @@ export { encodeSdJwtDisclosure as encodeDisclosure, } from '@docknetwork/crypto-utils/vc'; +export { + checkoutReceiptSchema, + paymentReceiptSchema, + checkoutMandateOpenSchema, + checkoutMandateClosedSchema, + paymentMandateOpenSchema, + paymentMandateClosedSchema, +}; + export const RECEIPT_TYPE_CHECKOUT = 'checkout'; export const RECEIPT_TYPE_PAYMENT = 'payment'; export const DEFAULT_CLOCK_TOLERANCE = 30; diff --git a/packages/ap2/upstream-ap2-schemas/checkout_mandate.json b/packages/ap2/upstream-ap2-schemas/checkout_mandate.json new file mode 100644 index 000000000..8755db60a --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/checkout_mandate.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/checkout_mandate.json", + "title": "Checkout Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Checkout action.", + "type": "object", + "required": [ + "vct", + "checkout_jwt", + "checkout_hash" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout'.", + "const": "mandate.checkout.1", + "default": "mandate.checkout.1" + }, + + "checkout_jwt": { + "type": "string", + "description": "base64url-encoded serialized merchant-signed JWT of the Checkout payload.", + "x-selectively-disclosable-field": true + }, + "checkout_hash": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying this checkout. If this checkout mandate is presented as an sd-jwt and the _sd_alg field is present then the hash algorithm used MUST match the _sd_alg field. Otherwise, sha-256 MUST be used." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/checkout_receipt.json b/packages/ap2/upstream-ap2-schemas/checkout_receipt.json new file mode 100644 index 000000000..3268a578e --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/checkout_receipt.json @@ -0,0 +1,68 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/checkout_receipt.json", + "title": "Checkout Receipt", + "description": "Receipt that supplies information about the final state of a checkout.", + "type": "object", + "properties": { + "status": { + "description": "The status of the checkout.", + "$ref": "types/receipt_status.json" + }, + "iss": { + "type": "string", + "description": "The issuer of the receipt." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "reference": { + "type": "string", + "description": "The hash of the closed Mandate that this receipt is binding to." + }, + "error": { + "type": "string", + "description": "A unique error code. Present if and only if status is Error." + }, + "error_description": { + "type": "string", + "description": "A human-readable error description. Present if and only if status is Error." + }, + "order_id": { + "type": "string", + "description": "A reference to the order for the checkout. Present if and only if status is Success." + } + }, + "required": [ + "status", + "iss", + "iat", + "reference" + ], + "oneOf": [ + { + "title": "Checkout Receipt Success", + "properties": { + "status": { + "const": "Success" + } + }, + "required": [ + "order_id" + ] + }, + { + "title": "Checkout Receipt Error", + "properties": { + "status": { + "const": "Error" + } + }, + "required": [ + "error", + "error_description" + ] + } + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json b/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json new file mode 100644 index 000000000..e3d9cafa7 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/open_checkout_mandate.json @@ -0,0 +1,145 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/open_checkout_mandate", + "title": "Open Checkout Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future checkout actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.checkout.open'.", + "const": "mandate.checkout.open.1", + "default": "mandate.checkout.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/allowed_merchants" + }, + { + "$ref": "#/$defs/line_items" + } + ] + }, + "contains": { + "$ref": "#/$defs/line_items" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim defined in RFC 7800 section 3.1. Used for key binding." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_merchants": { + "type": "object", + "description": "Defines the set of possible merchants for this Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.allowed_merchants", + "default": "checkout.allowed_merchants" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "$ref": "types/merchant.json" + }, + "x-selectively-disclosable-array": true + } + }, + "required": [ + "type", + "allowed" + ] + }, + "line_items": { + "type": "object", + "description": "Defines the sets of line items that are to be present in the Checkout Mandate.", + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "checkout.line_items", + "default": "checkout.line_items" + }, + "items": { + "type": "array", + "description": "Array of line item requirements.", + "items": { + "$ref": "#/$defs/line_item_requirements" + }, + "minItems": 1 + } + }, + "required": [ + "type", + "items" + ] + }, + "line_item_requirements": { + "type": "object", + "description": "Defines a line item that must be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Identifier for the line item requirement." + }, + "acceptable_items": { + "type": "array", + "description": "Defines a set of line items that are acceptable for this line item requirement. One and only one must be present in the Checkout Mandate.", + "items": { + "$ref": "#/$defs/item" + }, + "x-selectively-disclosable-array": true + }, + "quantity": { + "type": "integer", + "description": "Required quantity of matching items.", + "exclusiveMinimum": 0 + } + }, + "required": [ + "id", + "acceptable_items", + "quantity" + ] + }, + "item": { + "type": "object", + "description": "Defines an item that may be present in the Checkout Mandate.", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the line item. Will often be the SKU." + }, + "title": { + "type": "string", + "description": "Title of the item." + } + }, + "required": [ + "id", + "title" + ] + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json b/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json new file mode 100644 index 000000000..22c9dcaa1 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/open_payment_mandate.json @@ -0,0 +1,294 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_mandate_open.json", + "title": "Open Payment Mandate", + "description": "Agreement between a user and an agent (or chain of agents) to authorize future payment actions.", + "type": "object", + "required": [ + "vct", + "constraints", + "cnf" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment.open'.", + "const": "mandate.payment.open.1", + "default": "mandate.payment.open.1" + }, + "constraints": { + "type": "array", + "description": "Array of constraints that the future checkout action must abide by.", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/agent_recurrence" + }, + { + "$ref": "#/$defs/allowed_payees" + }, + { + "$ref": "#/$defs/allowed_payment_instruments" + }, + { + "$ref": "#/$defs/allowed_pisps" + }, + { + "$ref": "#/$defs/amount_range" + }, + { + "$ref": "#/$defs/budget" + }, + { + "$ref": "#/$defs/execution_date" + }, + { + "$ref": "#/$defs/payment_reference" + } + ] + }, + "contains": { + "$ref": "#/$defs/payment_reference" + } + }, + "cnf": { + "type": "object", + "description": "Confirmation claim as defined in RFC 7800 section 3.1. Used for key binding." + }, + "payee": { + "description": "The merchant receiving the payment.", + "$ref": "types/merchant.json" + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Pre-set by the user at time of mandate creation.", + "$ref": "types/amount.json" + }, + "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "types/payment_instrument.json" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "types/pisp.json" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + }, + "$defs": { + "allowed_payees": { + "type": "object", + "description": "Defines the set of possible payees.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payees", + "default": "payment.allowed_payees" + }, + "allowed": { + "type": "array", + "description": "Array of allowed Merchant objects.", + "items": { + "$ref": "types/merchant.json" + }, + "x-selectively-disclosable-array": true + } + } + }, + "amount_range": { + "type": "object", + "description": "Defines the valid range for the payment amount", + "required": [ + "type", + "currency", + "max" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.amount_range", + "default": "payment.amount_range" + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 currency code." + }, + "max": { + "type": "integer", + "description": "Maximum allowed amount in minor (cents) unit of currency." + }, + "min": { + "type": "integer", + "description": "Minimal amount in minor (cents) unit of currency. If absent, there is no minimum." + } + } + }, + "payment_reference": { + "type": "object", + "description": "Constrains the payment to a specific checkout reference.", + "required": [ + "type", + "conditional_transaction_id" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.reference", + "default": "payment.reference" + }, + "conditional_transaction_id": { + "type": "string", + "description": "Digest of the associated Open Checkout Mandate." + } + } + }, + "agent_recurrence": { + "type": "object", + "description": "Provides conditions for the agent to reuse this Payment Mandate multiple times.", + "required": [ + "type", + "frequency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.agent_recurrence", + "default": "payment.agent_recurrence" + }, + "frequency": { + "type": "string", + "description": "Frequency of allowed recurrences.", + "enum": [ + "ON_DEMAND", + "DAILY", + "WEEKLY", + "BIWEEKLY", + "MONTHLY", + "QUARTERLY", + "ANNUALLY" + ] + }, + "max_occurrences": { + "type": "integer", + "description": "Maximum number of allowed occurrences." + } + } + }, + "allowed_payment_instruments": { + "type": "object", + "description": "Defines the set of possible payment instruments for this Payment Mandate.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_payment_instruments", + "default": "payment.allowed_payment_instruments" + }, + "allowed": { + "type": "array", + "description": "Array of allowed payment instruments.", + "items": { + "$ref": "types/payment_instrument.json" + }, + "x-selectively-disclosable-array": true + } + } + }, + "allowed_pisps": { + "type": "object", + "description": "Defines the set of Payment Initiation Service Providers (PISPs) authorized to facilitate the transaction.", + "required": [ + "type", + "allowed" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.allowed_pisps", + "default": "payment.allowed_pisps" + }, + "allowed": { + "type": "array", + "description": "Array of allowed PISPs.", + "items": { + "$ref": "types/pisp.json" + } + } + } + }, + "budget": { + "type": "object", + "description": "Defines the maximum total amount that can be spent when using the payment.agent_recurrence constraint.", + "required": [ + "type", + "max", + "currency" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.budget", + "default": "payment.budget" + }, + "max": { + "type": "number", + "description": "Maximum amount for the budget." + }, + "currency": { + "type": "string", + "description": "ISO4217 Alpha-3 defining the currency of the amount." + } + } + }, + "execution_date": { + "type": "object", + "description": "Defines the valid time window for the payment execution.", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "description": "Constraint type identifier.", + "const": "payment.execution_date", + "default": "payment.execution_date" + }, + "not_before": { + "type": "string", + "description": "Earliest valid execution date." + }, + "not_after": { + "type": "string", + "description": "Latest valid execution date." + } + } + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/payment_mandate.json b/packages/ap2/upstream-ap2-schemas/payment_mandate.json new file mode 100644 index 000000000..fa93b0dfd --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/payment_mandate.json @@ -0,0 +1,58 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_mandate.json", + "title": "Payment Mandate", + "description": "Agreement from a User or an Agent to authorize a particular Payment action.", + "type": "object", + "required": [ + "vct", + "transaction_id", + "payee", + "payment_amount", + "payment_instrument" + ], + "properties": { + "vct": { + "type": "string", + "description": "Verifiable Credential Type claim as defined in SD-JWT. MUST be 'mandate.payment'.", + "const": "mandate.payment.1", + "default": "mandate.payment.1" + }, + "transaction_id": { + "type": "string", + "description": "base64url-encoded hash of the checkout_jwt field value, uniquely identifying the checkout associated with this. The hash algorithm used MUST be the same as the sd_hash field for this sd-jwt, or sha256 if absent." + }, + "payee": { + "description": "The merchant receiving the payment.", + "$ref": "types/merchant.json" + }, + "pisp": { + "description": "The Payment Initiation Service Provider.", + "$ref": "types/pisp.json" + }, + "payment_amount": { + "description": "Transaction amount object containing currency (ISO 4217 code, e.g., \"USD\") and amount (integer minor units per ISO 4217, e.g., 27999 = $279.99). Final value confirmed by the user.", + "$ref": "types/amount.json" + }, + "payment_instrument": { + "description": "The payment instrument used.", + "$ref": "types/payment_instrument.json" + }, + "execution_date": { + "type": "string", + "description": "ISO8601 date of execution of payment. When absent indicates immediate execution." + }, + "risk_data": { + "type": "object", + "description": "An map of relevant risk signals collected by the trusted surface at time of mandate creation." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "exp": { + "type": "integer", + "description": "The expiration timestamp as a Unix epoch." + } + } +} diff --git a/packages/ap2/upstream-ap2-schemas/payment_receipt.json b/packages/ap2/upstream-ap2-schemas/payment_receipt.json new file mode 100644 index 000000000..f58ef0450 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/payment_receipt.json @@ -0,0 +1,78 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/payment_receipt.json", + "title": "Payment Receipt", + "description": "Receipt that supplies information about the final state of a payment.", + "type": "object", + "properties": { + "status": { + "description": "The status of the payment.", + "$ref": "types/receipt_status.json" + }, + "iss": { + "type": "string", + "description": "The issuer of the receipt." + }, + "iat": { + "type": "integer", + "description": "The creation timestamp as a Unix epoch." + }, + "reference": { + "type": "string", + "description": "The hash of the closed Mandate that this receipt is binding to." + }, + "error": { + "type": "string", + "description": "A unique error code. Present if and only if status is Error." + }, + "error_description": { + "type": "string", + "description": "A human-readable error description. Present if and only if status is Error." + }, + "payment_id": { + "type": "string", + "description": "A unique identifier for the payment." + }, + "psp_confirmation_id": { + "type": "string", + "description": "A unique identifier for the transaction confirmation at the PSP. Present only if status is Success." + }, + "network_confirmation_id": { + "type": "string", + "description": "A unique identifier for the transaction confirmation at the network. Present only if status is Success." + } + }, + "required": [ + "status", + "iss", + "iat", + "reference", + "payment_id" + ], + "oneOf": [ + { + "title": "Payment Receipt Success", + "properties": { + "status": { + "const": "Success" + } + }, + "required": [ + "psp_confirmation_id", + "network_confirmation_id" + ] + }, + { + "title": "Payment Receipt Error", + "properties": { + "status": { + "const": "Error" + } + }, + "required": [ + "error", + "error_description" + ] + } + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/amount.json b/packages/ap2/upstream-ap2-schemas/types/amount.json new file mode 100644 index 000000000..0631cd413 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/amount.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/amount.json", + "title": "Amount", + "description": "Schema defining an Amount and Current object", + "type": "object", + "properties": { + "amount": { + "type": "integer", + "description": "Amount in minor units, according to the ISO-4217 spec." + }, + "currency": { + "type": "string", + "description": "ISO-4217 3-letter alphabetic currency code of the payment." + } + }, + "required": [ + "amount", + "currency" + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/merchant.json b/packages/ap2/upstream-ap2-schemas/types/merchant.json new file mode 100644 index 000000000..2e44a4dc9 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/merchant.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/merchant.json", + "title": "Merchant", + "description": "Schema defining a Mechant object", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the merchant." + }, + "name": { + "type": "string", + "description": "Human-readable name of the merchant." + }, + "website": { + "type": "string", + "description": "Website belonging to the merchant." + } + }, + "required": [ + "id", + "name" + ] +} \ No newline at end of file diff --git a/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json b/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json new file mode 100644 index 000000000..99857b370 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/payment_instrument.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/payment_instrument.json", + "title": "Payment Instrument", + "description": "Instrument used for payment.", + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "unique identifier for this instrument" + }, + "type": { + "type": "string", + "description": "unique string identifying this category of instrument" + }, + "description": { + "type": "string", + "description": "Description of the instrument to be displayed to the user for informational purposes" + } + }, + "required": [ + "id", + "type" + ] +} \ No newline at end of file diff --git a/packages/ap2/upstream-ap2-schemas/types/pisp.json b/packages/ap2/upstream-ap2-schemas/types/pisp.json new file mode 100644 index 000000000..db63c7e5f --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/pisp.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/types/pisp.json", + "title": "PISP", + "description": "Schema defining a Payment Initiation Service Provider (PISP) object", + "type": "object", + "properties": { + "legal_name": { + "type": "string", + "description": "Legal name of the PISP." + }, + "brand_name": { + "type": "string", + "description": "Brand name of the PISP." + }, + "domain_name": { + "type": "string", + "description": "Domain name of the PISP as secured by the [eIDAS] QWAC certificate of the TPP." + } + }, + "required": [ + "legal_name", + "brand_name", + "domain_name" + ] +} diff --git a/packages/ap2/upstream-ap2-schemas/types/receipt_status.json b/packages/ap2/upstream-ap2-schemas/types/receipt_status.json new file mode 100644 index 000000000..f53bd8ba2 --- /dev/null +++ b/packages/ap2/upstream-ap2-schemas/types/receipt_status.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://ap2-protocol.org/schemas/receipt-status.json", + "title": "Receipt Status", + "description": "The status of a receipt.", + "type": "string", + "enum": [ + "Success", + "Error" + ] +} From faeaf808627d9875c90975df50c5db4e03ebc532 Mon Sep 17 00:00:00 2001 From: Mike Parkhill Date: Thu, 23 Jul 2026 17:56:36 -0400 Subject: [PATCH 9/9] feat(ap2): add resolveOpenPaymentMandateContent Open Payment Mandates have no verify* counterpart today (only the Closed mandate verifiers exist), so no consumer can read back a mandate's own budget/allowed_payees/allowed_payment_instruments constraints without hand-rolling SD-JWT disclosure resolution. resolveOpenPaymentMandateContent parses and resolves an Open Payment Mandate presentation's content -- including per-array-element disclosures for payment.allowed_payees/payment.allowed_payment_instruments -- validates it against the Open Payment Mandate schema, and checks exp when present. It deliberately does not verify an issuer signature: Open Payment Mandates are signed by the User's key, which is generally a different key from the mandate's own cnf.jwk, so there is no single key this package can check the envelope against on its own -- a caller that independently knows the issuing key can verify it separately. Co-Authored-By: Claude Sonnet 5 --- packages/ap2/src/mandates.js | 76 ++++++++++++++++++- packages/ap2/tests/mandates.test.js | 110 ++++++++++++++++++++++++++++ 2 files changed, 185 insertions(+), 1 deletion(-) diff --git a/packages/ap2/src/mandates.js b/packages/ap2/src/mandates.js index 9c9303d2b..d7712e88e 100644 --- a/packages/ap2/src/mandates.js +++ b/packages/ap2/src/mandates.js @@ -6,6 +6,8 @@ import { parseSdJwtPresentation, computeSdHash, jwkToSecp256r1PublicKey, + decodeJwtPayload, + decodeJwtProtectedHeader, } from '@docknetwork/crypto-utils/vc'; import { MANDATE_TYPE_CHECKOUT_OPEN, @@ -23,6 +25,8 @@ export { MANDATE_TYPE_PAYMENT_CLOSED, } from './utils'; +const OPEN_PAYMENT_MANDATE_LABEL = 'Open Payment Mandate'; + const DEFAULT_SD_ALG = 'sha-256'; const SD_HASH_ALGORITHMS = { 'sha-256': 'sha256', @@ -574,7 +578,7 @@ export function verifyClosedPaymentMandate( const sdHashVerified = checkSdHashAgainstOpenMandate( payload, openMandatePresentation, - 'Open Payment Mandate', + OPEN_PAYMENT_MANDATE_LABEL, ); return { @@ -588,3 +592,73 @@ export function verifyClosedPaymentMandate( return { verified: false, error: error instanceof Error ? error : new Error(String(error)) }; } } + +// Reveals the array-element disclosures inside an Open Payment Mandate's +// resolved content -- the reverse of `redactPaymentConstraints`. Entries that +// are already plain objects (not SD-redacted, e.g. from a hand-built content +// object that skipped signing) are passed through unchanged. +function resolvePaymentConstraintDisclosures(content, disclosures, sdAlg) { + const disclosableTypes = ['payment.allowed_payees', 'payment.allowed_payment_instruments']; + const constraints = (content.constraints ?? []).map((constraint) => { + if (!disclosableTypes.includes(constraint?.type) || !Array.isArray(constraint.allowed)) { + return constraint; + } + const allowed = constraint.allowed.map((entry) => { + const digest = entry?.['...']; + if (typeof digest !== 'string') { + return entry; + } + const disclosure = findDisclosureByDigest(disclosures, digest, sdAlg); + if (!disclosure) { + throw new Error(`No disclosure found for a "${constraint.type}" array-element digest`); + } + return decodeArrayElementDisclosure(disclosure); + }); + return { ...constraint, allowed }; + }); + return { ...content, constraints }; +} + +/** + * Decodes and resolves an Open Payment Mandate presentation's own content + * (`constraints`, `cnf`, `exp`, ...) -- WITHOUT verifying an issuer signature. + * + * Open Payment Mandates are signed by the User's key (see + * `signOpenPaymentMandate`), which is generally a *different* key from the + * mandate's own `cnf.jwk` (the Agent's key, only endorsed to close it later) + * -- so, unlike `verifyClosedCheckoutMandate`/`verifyClosedPaymentMandate`, + * there is no single key this package can check the envelope signature + * against on its own. A caller that independently knows the issuing User's + * public key can verify the envelope itself (`verifyJWT`, on the `issuerJwt` + * half of `parseSdJwtPresentation`'s result) before or after calling this. + * + * What this DOES check: the SD-JWT's internal digest/disclosure consistency + * (a hand-edited constraints blob whose digest no longer matches + * `delegate_payload` is rejected), that the resolved content matches the + * Open Payment Mandate schema, and (when `exp` is present) that it hasn't + * passed. + * + * @param {string} presentation + * @param {{currentDate?: Date, clockTolerance?: number}} options + * @returns {{content: object, protectedHeader: object}} + */ +export function resolveOpenPaymentMandateContent( + presentation, + { currentDate = new Date(), clockTolerance = DEFAULT_CLOCK_TOLERANCE } = {}, +) { + const { issuerJwt, disclosures } = parseSdJwtPresentation(presentation); + const payload = decodeJwtPayload(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); + const protectedHeader = decodeJwtProtectedHeader(issuerJwt, OPEN_PAYMENT_MANDATE_LABEL); + const sdAlg = getSdAlg(payload); + + const rawContent = resolveMandateContent(payload, disclosures, sdAlg); + const resolvedContent = resolvePaymentConstraintDisclosures(rawContent, disclosures, sdAlg); + const content = buildOpenPaymentMandate(resolvedContent); + + // Unlike the Closed mandate envelopes, `exp` lives on the disclosed content + // itself, not on the outer signed envelope payload (which is just + // `{delegate_payload, _sd_alg}`) -- so it's checked against `content` here. + checkNotExpired(content, { currentDate, clockTolerance }, OPEN_PAYMENT_MANDATE_LABEL); + + return { content, protectedHeader }; +} diff --git a/packages/ap2/tests/mandates.test.js b/packages/ap2/tests/mandates.test.js index ff9b289d5..67d457fd5 100644 --- a/packages/ap2/tests/mandates.test.js +++ b/packages/ap2/tests/mandates.test.js @@ -19,6 +19,7 @@ import { signClosedPaymentMandate, verifyClosedCheckoutMandate, verifyClosedPaymentMandate, + resolveOpenPaymentMandateContent, computeDisclosureDigest, computeCheckoutHash, } from '../src'; @@ -249,3 +250,112 @@ describe('AP2 mandates: round trip', () => { expect(result.verified).toBe(false); }); }); + +describe('AP2 mandates: resolveOpenPaymentMandateContent', () => { + const merchant = { id: 'merchant_1', name: 'Demo Merchant', website: 'https://demo-merchant.example' }; + const instrument = { id: 'card_1', type: 'card', description: 'Card ****4242' }; + + test('resolves budget, allowed_payees, and allowed_payment_instruments constraints', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [ + { type: 'payment.budget', max: 20000, currency: 'USD' }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.allowed_payment_instruments', allowed: [instrument] }, + { type: 'payment.reference', conditional_transaction_id: 'digest-1' }, + ], + cnf, + }), + { signer: userKeypair }, + ); + + const { content, protectedHeader } = resolveOpenPaymentMandateContent(presentation); + + expect(content.cnf).toEqual(cnf); + expect(protectedHeader.typ).toBe('dc+sd-jwt'); + expect(content.constraints).toEqual( + expect.arrayContaining([ + { type: 'payment.budget', max: 20000, currency: 'USD' }, + { type: 'payment.allowed_payees', allowed: [merchant] }, + { type: 'payment.allowed_payment_instruments', allowed: [instrument] }, + ]), + ); + }); + + test('rejects a hand-edited presentation whose content no longer matches its digest', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [ + { type: 'payment.budget', max: 100, currency: 'USD' }, + { type: 'payment.reference', conditional_transaction_id: 'digest-1' }, + ], + cnf, + }), + { signer: userKeypair }, + ); + + // Hand-edit the content disclosure (the last '~'-delimited segment) to + // claim a much larger budget, without re-signing. + const parts = presentation.split('~'); + const contentDisclosure = parts[parts.length - 2]; + const decoded = JSON.parse(Buffer.from(contentDisclosure, 'base64url').toString('utf8')); + decoded[1].constraints[0].max = 999999999; + const tamperedDisclosure = Buffer.from(JSON.stringify(decoded)).toString('base64url'); + const tamperedPresentation = [...parts.slice(0, -2), tamperedDisclosure, ''].join('~'); + + expect(() => resolveOpenPaymentMandateContent(tamperedPresentation)).toThrow( + /No disclosure found/, + ); + }); + + test('rejects an expired Open Payment Mandate', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + iat: 1000, + exp: 2000, + }), + { signer: userKeypair }, + ); + + expect(() => resolveOpenPaymentMandateContent(presentation, { + currentDate: new Date(3000 * 1000), + })).toThrow(/expired/); + }); + + test('accepts a mandate with no exp -- no expiry enforced', async () => { + const userKeypair = Secp256r1Keypair.random(); + const agentKeypair = Secp256r1Keypair.random(); + const cnf = { jwk: secp256r1PublicKeyToJwk(agentKeypair.publicKey()) }; + + const presentation = await signOpenPaymentMandate( + buildOpenPaymentMandate({ + vct: 'mandate.payment.open.1', + constraints: [{ type: 'payment.reference', conditional_transaction_id: 'digest-1' }], + cnf, + }), + { signer: userKeypair }, + ); + + const { content } = resolveOpenPaymentMandateContent(presentation, { + currentDate: new Date(Date.now() + 1000 * 365 * 24 * 60 * 60 * 1000), + }); + expect(content.vct).toBe('mandate.payment.open.1'); + }); +});