Skip to content
Open
24 changes: 24 additions & 0 deletions packages/ap2/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
113 changes: 104 additions & 9 deletions packages/ap2/README.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand All @@ -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, {
Expand Down Expand Up @@ -66,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
Expand All @@ -90,6 +93,98 @@ 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`).

`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
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.

**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:
Expand Down
3 changes: 2 additions & 1 deletion packages/ap2/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@docknetwork/ap2",
"version": "0.2.1",
"version": "0.3.0",
"description": "Basic AP2 support",
"license": "MIT",
"type": "module",
Expand Down Expand Up @@ -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\"",
Expand Down
98 changes: 98 additions & 0 deletions packages/ap2/scripts/generate-schemas.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
8 changes: 8 additions & 0 deletions packages/ap2/src/index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
export * from './receipts';
export * from './mandates';
export { computeSdHash } from '@docknetwork/crypto-utils/vc';
export {
encodeDisclosure,
inferReceiptType,
validateReceipt,
validateReceiptTime,
validateMandateContent,
checkoutReceiptSchema,
paymentReceiptSchema,
checkoutMandateOpenSchema,
checkoutMandateClosedSchema,
paymentMandateOpenSchema,
paymentMandateClosedSchema,
} from './utils';
Loading
Loading