/**
* AP2 Mandate Signer (Merchant-side)
* Signs cart mandates as JWS compact serialization tokens
* and generates ES256 key pairs for AP2 mandate operations.
*/
import { SignJWT, generateKeyPair, exportJWK, importJWK } from 'jose';
import type { JWK, CryptoKey } from 'jose';
import { v4 as uuidv4 } from 'uuid';
import type { CheckoutSession, CartPayload, Mandate } from '../types.js';
import type { SigningKeyPair } from './types.js';
import { generateMerchantSignature } from './merchant-sig.js';
export class MandateSigner {
private privateKey: CryptoKey | null = null;
private readonly jwk: JWK;
private readonly kid: string;
private readonly merchantSecret: string;
/**
* @param privateJwk - Private JWK (ES256) for signing mandates.
* Must include the "d" parameter (private key component).
* @param merchantSecret - Secret used for HMAC merchant_signature (defaults to JWK kid).
*/
constructor(privateJwk: JWK, merchantSecret?: string) {
this.jwk = privateJwk;
this.kid = privateJwk.kid ?? uuidv4();
this.merchantSecret = merchantSecret ?? this.kid;
}
/** Lazily import the JWK into a CryptoKey usable by jose. */
private async ensureKey(): Promise<CryptoKey> {
if (!this.privateKey) {
this.privateKey = await importJWK(this.jwk, 'ES256') as CryptoKey;
}
return this.privateKey;
}
/**
* Sign a cart mandate for the given checkout session.
* Produces a JWS compact serialization string containing the full Mandate payload.
*
* @param checkoutSession - The checkout session whose cart contents to sign.
* @returns JWS compact serialization (header.payload.signature)
*/
async signCartMandate(checkoutSession: CheckoutSession): Promise<string> {
const key = await this.ensureKey();
const now = new Date();
const expiresAt = new Date(now.getTime() + 30 * 60 * 1000); // 30 minutes
const mandateId = `mandate_cart_${uuidv4()}`;
// Generate merchant_signature: HMAC-SHA256 of cart contents
const merchantSig = generateMerchantSignature(
{
checkout_id: checkoutSession.id,
line_items: checkoutSession.line_items,
totals: checkoutSession.totals,
},
this.merchantSecret,
);
const cartPayload: CartPayload = {
checkout_id: checkoutSession.id,
line_items: checkoutSession.line_items,
totals: checkoutSession.totals,
merchant_signature: merchantSig,
};
const mandate: Mandate = {
id: mandateId,
type: 'cart',
status: 'active',
issuer: checkoutSession.id, // merchant identified by checkout context
subject: checkoutSession.buyer?.email ?? 'anonymous',
payload: cartPayload,
signature: '', // will be the JWS itself
issued_at: now.toISOString(),
expires_at: expiresAt.toISOString(),
};
const token = await new SignJWT(mandate as unknown as Record<string, unknown>)
.setProtectedHeader({
alg: 'ES256',
kid: this.kid,
typ: 'mandate+jwt',
})
.setIssuedAt()
.setExpirationTime(expiresAt)
.setJti(mandateId)
.sign(key);
return token;
}
/**
* Generate a new ES256 key pair suitable for AP2 mandate signing/verification.
*
* @returns A SigningKeyPair with public, private JWKs and a generated kid.
*/
static async generateKeyPair(): Promise<SigningKeyPair> {
const kid = `ap2_${uuidv4()}`;
const { publicKey, privateKey } = await generateKeyPair('ES256', { extractable: true });
const publicJwk = await exportJWK(publicKey);
const privateJwk = await exportJWK(privateKey);
// Embed kid in the JWK for downstream identification
publicJwk.kid = kid;
privateJwk.kid = kid;
return {
publicKey: publicJwk,
privateKey: privateJwk,
kid,
};
}
}