// Copyright 2024-2026 Tymofii Pidlisnyi. Apache-2.0 license. See LICENSE.
// ══════════════════════════════════════════════════════════════════
// ACP — Agentic Commerce Protocol adapter (OpenAI + Stripe)
// ══════════════════════════════════════════════════════════════════
// Public functions:
//   preAuthorizeAcpCheckout(req, delegation)  → permit | deny+reason
//   apsToAcpError(reason)                     → AcpErrorType + AcpErrorCode
//   signAcpReceipt(input, privateKeyHex)      → AcpReceipt
//   verifyAcpReceipt(receipt, signerPubHex)   → AcpVerifyResult
//   signAcpDenial(input, privateKeyHex)       → AcpDenial
//   verifyAcpDenial(denial, signerPubHex)     → AcpVerifyResult
//   delegationToAcpAllowed(delegation)        → { allowed_merchants, allowed_currencies, max_total }
//   acpSessionToDelegationHints(session)      → partial V2Delegation derivation
//
// Spec-ambiguity calls (also documented in docs/governance/acp-interop.md):
//   - V2Delegation.scope.action_categories does not map 1:1 to ACP
//     items (ACP is line-item based, APS is action-class based). We
//     surface allowed_merchants and a max_total cents budget; line-
//     item filtering is gateway product, not protocol primitive.
//   - ACP idempotency conflicts produce a deterministic
//     'idempotency_conflict' denial mapped to the ACP error type
//     'request_not_idempotent' with code 'invalid'. Caller maintains
//     the idempotency cache; this adapter only emits the receipt.
//   - Receipt session_state captures whatever the merchant returned;
//     we don't synthesize a session ourselves.
// ══════════════════════════════════════════════════════════════════
import { createHash, randomUUID } from 'node:crypto';
import { canonicalizeJCS } from '../../../core/canonical-jcs.js';
import { publicKeyFromPrivate, sign, verify as edVerify } from '../../../crypto/keys.js';
import { parseDidUri, publicKeyHexFromMethod, resolveVerificationMethod, } from '../../../core/did-uri.js';
/** Phase 4.1 / P12 — derive the receipt's signer string. When both
 *  agentId and keyRef are supplied, it's a DID URI; otherwise the
 *  legacy raw hex pubkey form. Compatible-superset. */
function _acpSignerFor(privateKeyHex, agentId, keyRef) {
    if (agentId && keyRef) {
        if (!agentId.startsWith('did:')) {
            throw new Error(`signAcp*: issuer_agent_id must be a DID, got '${agentId}'`);
        }
        if (keyRef.includes('#')) {
            throw new Error("signAcp*: issuer_key_ref must not contain '#'");
        }
        return `${agentId}#${keyRef}`;
    }
    return publicKeyFromPrivate(privateKeyHex);
}
import { verifyOwnerConfirmation } from '../../human-escalation.js';
import { csvToList } from '../csv.js';
import { resolveSpendLimitCents } from '../scope-resolution.js';
import { ACP_API_VERSION, } from './types.js';
export { ACP_API_VERSION } from './types.js';
// ── Internal canonical helpers ────────────────────────────────────
function sha256Hex(bytes) {
    const buf = typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : Buffer.from(bytes);
    return createHash('sha256').update(buf).digest('hex');
}
function canonicalDigest(obj) {
    return sha256Hex(canonicalizeJCS(obj));
}
function nowIso() {
    return new Date().toISOString();
}
// ── APS ↔ ACP error mapping ───────────────────────────────────────
/**
 * Deterministic mapping from an APS denial reason to the ACP error
 * envelope a merchant would have returned. This is the reverse of
 * the merchant's view: when APS gates an ACP call BEFORE it reaches
 * the merchant, we still emit the ACP-shaped error so the caller can
 * surface it identically to a real merchant response.
 */
export function apsToAcpError(reason) {
    switch (reason) {
        case 'spend_limit_exceeded':
            return { type: 'invalid_request', code: 'invalid', param: '$.items' };
        case 'merchant_not_allowed':
            return { type: 'invalid_request', code: 'invalid', param: '$.items[0].id' };
        case 'delegation_expired':
            return { type: 'invalid_request', code: 'requires_sign_in' };
        case 'currency_mismatch':
            return { type: 'invalid_request', code: 'invalid', param: '$.currency' };
        case 'wallet_revoked':
            return { type: 'invalid_request', code: 'payment_declined' };
        case 'no_commerce_scope':
            return { type: 'invalid_request', code: 'requires_sign_in' };
        case 'idempotency_conflict':
            return { type: 'request_not_idempotent', code: 'invalid' };
        case 'invalid_session_state':
            return { type: 'invalid_request', code: 'invalid', param: '$.status' };
        case 'api_version_mismatch':
            return { type: 'service_unavailable', code: 'invalid' };
        case 'requires_owner_confirmation':
            return { type: 'invalid_request', code: 'requires_sign_in' };
    }
}
// ── Tier-2 → Tier-1 vocab crosswalk (Audit B P5) ──────────────────
/**
 * Map an ACP-specific denial reason to the foundation Tier-1
 * DenialReason taxonomy. Generic gateways and audit-log consumers read
 * Tier 1; rail-aware clients can keep reading Tier 2.
 *
 * Mapping policy (also documented in
 * docs/governance/payment-rails-denial-vocabulary.md):
 *   - Direct carryovers stay as themselves
 *     (spend_limit_exceeded, wallet_revoked, no_commerce_scope)
 *   - delegation_expired → time_window_violation
 *     (foundation models all expiry as time-window failures)
 *   - merchant_not_allowed / currency_mismatch / idempotency_conflict
 *     / invalid_session_state / api_version_mismatch
 *     / requires_owner_confirmation → rail_error
 *     (no exact Tier-1 analog; Tier-2 carries the precise reason for
 *     ACP-aware consumers)
 */
export function mapAcpDenialToFoundation(reason) {
    switch (reason) {
        case 'spend_limit_exceeded':
            return 'spend_limit_exceeded';
        case 'wallet_revoked':
            return 'wallet_revoked';
        case 'no_commerce_scope':
            return 'no_commerce_scope';
        case 'delegation_expired':
            return 'time_window_violation';
        case 'merchant_not_allowed':
        case 'currency_mismatch':
        case 'idempotency_conflict':
        case 'invalid_session_state':
        case 'api_version_mismatch':
            return 'rail_error';
        case 'requires_owner_confirmation':
            return 'requires_owner_confirmation';
    }
}
/**
 * Project a V2Delegation into the subset of ACP context it permits.
 * Used by preAuthorizeAcpCheckout and by callers that want to render
 * a delegation as a buyer-facing summary before initiating a session.
 *
 * Field sourcing (matches AP2 / MPP / Stripe-Issuing conventions):
 *   - max_total ← resolveSpendLimitCents(delegation)
 *     [walks resource_limits.spend_limit_cents → commerce.spend_limit
 *     alias → constraints.spend_limit_cents string]
 *   - allowed_merchants ← scope.constraints.allowed_merchants (CSV)
 *   - allowed_currencies ← scope.constraints.allowed_currencies (CSV)
 *   - valid_until ← policy_context.valid_until
 */
export function delegationToAcpAllowed(delegation) {
    const constraints = delegation.scope?.constraints ?? {};
    return {
        allowed_merchants: csvToList(constraints.allowed_merchants),
        allowed_currencies: csvToList(constraints.allowed_currencies).map((s) => s.toLowerCase()),
        max_total: resolveSpendLimitCents(delegation),
        valid_until: delegation.policy_context?.valid_until,
    };
}
// ── Crosswalk: ACP CheckoutSession → V2Delegation hints ───────────
/**
 * One-way derivation: given a CheckoutSession the merchant returned,
 * compute the V2Delegation fields a downstream gateway could pin
 * against. Lossy by design — line-item content does not survive.
 */
export function acpSessionToDelegationHints(session) {
    const totalEntry = session.totals.find((t) => t.type === 'total');
    const totalAmount = totalEntry?.amount ?? 0;
    const merchant = session.payment_provider?.provider;
    return {
        scope: {
            action_categories: ['commerce'],
            constraints: {
                allowed_merchants: merchant ? [merchant] : undefined,
                allowed_currencies: [session.currency.toLowerCase()],
                spend_limit_cents: totalAmount,
            },
        },
        notes: [
            'lossy: line-item content not preserved in V2Delegation derivation',
            'PSP merchant identifier may differ from settlement merchant id',
        ],
    };
}
/** Action class ACP operations slot into for HumanEscalationFlag matching. */
const ACP_ACTION_CLASS = 'commerce';
/**
 * Decide whether a checkout-session request is permitted under a
 * delegation BEFORE the agent calls the merchant. Pure function;
 * no I/O, no side effects, no state. Intended to be the rail-side
 * hook that emits a signed receipt or denial after this returns.
 */
export function preAuthorizeAcpCheckout(request, delegation, 
/** Currency of the merchant's catalog; ACP carries this only on the response. */
expectedCurrency, options = {}) {
    // 1. Delegation must include a 'commerce' action category.
    const actionCategories = delegation.scope?.action_categories ?? [];
    if (!actionCategories.includes('commerce')) {
        return { allow: false, reason: 'no_commerce_scope', detail: 'delegation lacks commerce scope' };
    }
    // 2. Delegation must not be expired.
    const validUntil = delegation.policy_context?.valid_until;
    if (validUntil) {
        const expiresAt = Date.parse(validUntil);
        if (Number.isFinite(expiresAt) && Date.now() > expiresAt) {
            return { allow: false, reason: 'delegation_expired', detail: validUntil };
        }
    }
    // 2.5. HumanEscalationFlag — Audit B P9. If the delegation declares
    // escalation_requirements on the 'commerce' action class with
    // requires_owner_confirmation: true, the caller MUST supply a valid
    // OwnerConfirmation signed by delegator. Without it, deny. With an
    // invalid one, deny too.
    const reqs = delegation.scope?.escalation_requirements;
    const matchingReq = reqs?.find((r) => r.action_class === ACP_ACTION_CLASS && r.requires_owner_confirmation);
    if (matchingReq) {
        if (!options.owner_confirmation) {
            return {
                allow: false,
                reason: 'requires_owner_confirmation',
                detail: `action_class '${ACP_ACTION_CLASS}' requires owner confirmation`,
            };
        }
        const verdict = verifyOwnerConfirmation(options.owner_confirmation, {
            action_class: ACP_ACTION_CLASS,
            action_details: options.action_details ?? request,
            session_id: options.session_id ?? null,
        }, delegation, options.now ?? new Date());
        if (!verdict.valid) {
            return {
                allow: false,
                reason: 'requires_owner_confirmation',
                detail: `invalid owner_confirmation: ${verdict.reason}`,
            };
        }
    }
    const allowed = delegationToAcpAllowed(delegation);
    // 3. Currency check — only if the call provides expectedCurrency.
    if (expectedCurrency && allowed.allowed_currencies.length > 0) {
        if (!allowed.allowed_currencies.includes(expectedCurrency.toLowerCase())) {
            return {
                allow: false,
                reason: 'currency_mismatch',
                detail: `${expectedCurrency} not in [${allowed.allowed_currencies.join(',')}]`,
            };
        }
    }
    // 4. Empty items → invalid session state. Real spend gating runs
    // at session-complete time when authoritative totals are present.
    if ('items' in request && Array.isArray(request.items) && allowed.max_total !== null) {
        const qtyTotal = request.items.reduce((acc, it) => acc + (it.quantity ?? 0), 0);
        if (qtyTotal === 0) {
            return { allow: false, reason: 'invalid_session_state', detail: 'no items' };
        }
    }
    return { allow: true };
}
/**
 * Final spend check, run when a session has been retrieved or
 * completed and authoritative totals are in hand. Separate from
 * preAuthorizeAcpCheckout because totals are a server-side property.
 */
export function checkAcpSessionUnderBudget(session, delegation) {
    const allowed = delegationToAcpAllowed(delegation);
    if (allowed.max_total === null)
        return { allow: true };
    const totalEntry = session.totals.find((t) => t.type === 'total');
    if (!totalEntry) {
        return { allow: false, reason: 'invalid_session_state', detail: 'no total entry' };
    }
    if (totalEntry.amount > allowed.max_total) {
        return {
            allow: false,
            reason: 'spend_limit_exceeded',
            detail: `${totalEntry.amount} > ${allowed.max_total}`,
        };
    }
    if (allowed.allowed_currencies.length > 0) {
        if (!allowed.allowed_currencies.includes(session.currency.toLowerCase())) {
            return {
                allow: false,
                reason: 'currency_mismatch',
                detail: `${session.currency} not allowed`,
            };
        }
    }
    return { allow: true };
}
function defaultAcpReceiptScope() {
    return {
        asserts: 'aps:rail.acp:checkout — an ACP checkout-session op was authorized under the cited V2Delegation; request_digest binds the canonical body, session_state the merchant return.',
        does_not_assert: [
            'funds settled (ACP completion can still be reversed)',
            "merchant's legal identity matches payment_provider.provider",
            'goods or services were delivered',
            'idempotency was enforced (caller maintains the cache)',
        ],
        capture_mode: 'gateway_observed',
        completeness: 'complete',
        self_attested: false,
    };
}
export function signAcpReceipt(input, signerPrivateKeyHex) {
    const signerPub = _acpSignerFor(signerPrivateKeyHex, input.issuer_agent_id, input.issuer_key_ref);
    const requestDigest = canonicalDigest(input.request_body);
    const issued_at = nowIso();
    const useAccountabilityShape = input.accountability_shape === true || input.scope_of_claim !== undefined;
    const unsigned = {
        receipt_id: `acpr_${randomUUID()}`,
        receipt_kind: 'acp.checkout_session_op',
        acp_version: ACP_API_VERSION,
        op: input.op,
        session_id: input.session_id,
        delegation_ref: input.delegation_ref,
        agent_id: input.agent_id,
        signer: signerPub,
        session_state: input.session_state,
        request_digest: requestDigest,
        issued_at,
    };
    if (useAccountabilityShape) {
        unsigned.claim_type = 'rail.acp.v1';
        unsigned.timestamp = issued_at;
        unsigned.scope_of_claim = input.scope_of_claim ?? defaultAcpReceiptScope();
    }
    if (input.attribution_receipt_id !== undefined) {
        unsigned.attribution_receipt_id = input.attribution_receipt_id;
    }
    if (input.settlement_record_id !== undefined) {
        unsigned.settlement_record_id = input.settlement_record_id;
    }
    const sigBytes = canonicalizeJCS(unsigned);
    const signature = sign(sigBytes, signerPrivateKeyHex);
    return { ...unsigned, signature };
}
const VALID_OPS = new Set([
    'create',
    'update',
    'complete',
    'cancel',
    'retrieve',
]);
export function verifyAcpReceipt(receipt, options = {}) {
    const ttl = options.ttl_seconds ?? 24 * 60 * 60;
    if (receipt.acp_version !== ACP_API_VERSION) {
        return {
            valid: false,
            reason: 'INVALID_API_VERSION',
            detail: `expected ${ACP_API_VERSION} got ${receipt.acp_version}`,
        };
    }
    if (receipt.receipt_kind !== 'acp.checkout_session_op') {
        return { valid: false, reason: 'INVALID_RECEIPT_KIND', detail: receipt.receipt_kind };
    }
    if (!VALID_OPS.has(receipt.op)) {
        return { valid: false, reason: 'INVALID_OP', detail: receipt.op };
    }
    if (!receipt.session_id || !receipt.agent_id || !receipt.signer) {
        return { valid: false, reason: 'MISSING_REQUIRED_FIELD' };
    }
    if (options.expected_signer && receipt.signer !== options.expected_signer) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'signer mismatch' };
    }
    const issuedMs = Date.parse(receipt.issued_at);
    if (!Number.isFinite(issuedMs)) {
        return { valid: false, reason: 'MISSING_REQUIRED_FIELD', detail: 'issued_at' };
    }
    const nowMs = (options.now ?? new Date()).getTime();
    if (nowMs - issuedMs > ttl * 1000) {
        return { valid: false, reason: 'EXPIRED', detail: `older than ${ttl}s` };
    }
    // Phase 4.1 / Q1: when claim_type is set, additionally enforce the
    // accountability-shape invariants. Legacy receipts (no claim_type)
    // skip this block and verify under the existing path.
    if (receipt.claim_type !== undefined) {
        if (receipt.claim_type !== 'rail.acp.v1') {
            return { valid: false, reason: 'INVALID_RECEIPT_KIND', detail: `claim_type=${receipt.claim_type}` };
        }
        if (receipt.timestamp !== receipt.issued_at) {
            return { valid: false, reason: 'MISSING_REQUIRED_FIELD', detail: 'timestamp != issued_at' };
        }
        if (!receipt.scope_of_claim ||
            typeof receipt.scope_of_claim.asserts !== 'string' ||
            receipt.scope_of_claim.asserts.length === 0) {
            return { valid: false, reason: 'MISSING_REQUIRED_FIELD', detail: 'scope_of_claim' };
        }
    }
    // signature verify — strip sig, canonicalize, verify
    const { signature, ...rest } = receipt;
    const sigBytes = canonicalizeJCS(rest);
    if (typeof receipt.signer === 'string' && receipt.signer.startsWith('did:')) {
        return {
            valid: false,
            reason: 'DID_RESOLVER_MISSING',
            detail: 'use verifyAcpReceiptWithDID for DID-URI signers',
        };
    }
    if (!edVerify(sigBytes, signature, receipt.signer)) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'Ed25519 verify failed' };
    }
    return { valid: true };
}
/**
 * Phase 4.1 / P12: async verifier that resolves DID URIs against the
 * caller-supplied DID document resolver. Falls back to the legacy
 * raw-hex path when receipt.signer doesn't start with 'did:'.
 */
export async function verifyAcpReceiptWithDID(receipt, options = {}) {
    const sync = verifyAcpReceipt(receipt, options);
    // Reuse the sync function's structural checks (version, op, ttl, …).
    // It returns DID_RESOLVER_MISSING when signer is a DID URI; we replace
    // that with the async resolver path. All other failure reasons we
    // surface as-is.
    if (sync.valid)
        return sync;
    if (sync.reason !== 'DID_RESOLVER_MISSING')
        return sync;
    if (!options.resolveDidDocument) {
        return { valid: false, reason: 'DID_RESOLVER_MISSING' };
    }
    const parsed = parseDidUri(receipt.signer);
    if (!parsed)
        return { valid: false, reason: 'DID_URI_INVALID' };
    const didDoc = await options.resolveDidDocument(parsed.agentId);
    if (!didDoc)
        return { valid: false, reason: 'DID_DOC_NOT_FOUND' };
    const issuedMs = Date.parse(receipt.issued_at);
    const result = resolveVerificationMethod(didDoc, receipt.signer, options.now ? options.now.getTime() : undefined, Number.isFinite(issuedMs) ? issuedMs : undefined);
    if (!result)
        return { valid: false, reason: 'DID_KEY_NOT_IN_DOC' };
    if (result.retired)
        return { valid: false, reason: 'DID_KEY_RETIRED' };
    const pubHex = publicKeyHexFromMethod(result.method);
    const { signature, ...rest } = receipt;
    const sigBytes = canonicalizeJCS(rest);
    if (!edVerify(sigBytes, signature, pubHex)) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'Ed25519 verify failed' };
    }
    return { valid: true };
}
export function signAcpDenial(input, signerPrivateKeyHex) {
    const signerPub = _acpSignerFor(signerPrivateKeyHex, input.issuer_agent_id, input.issuer_key_ref);
    const requestDigest = canonicalDigest(input.request_body);
    const mapped = apsToAcpError(input.reason);
    const unsigned = {
        denial_id: `acpd_${randomUUID()}`,
        denial_kind: 'acp.checkout_session_denial',
        acp_version: ACP_API_VERSION,
        op: input.op,
        session_id: input.session_id,
        delegation_ref: input.delegation_ref,
        agent_id: input.agent_id,
        signer: signerPub,
        reason: input.reason,
        acp_error_code: mapped.code,
        acp_error_type: mapped.type,
        acp_error_param: mapped.param,
        request_digest: requestDigest,
        issued_at: nowIso(),
    };
    const sigBytes = canonicalizeJCS(unsigned);
    const signature = sign(sigBytes, signerPrivateKeyHex);
    return { ...unsigned, signature };
}
export function verifyAcpDenial(denial, options = {}) {
    const ttl = options.ttl_seconds ?? 24 * 60 * 60;
    if (denial.acp_version !== ACP_API_VERSION) {
        return { valid: false, reason: 'INVALID_API_VERSION' };
    }
    if (denial.denial_kind !== 'acp.checkout_session_denial') {
        return { valid: false, reason: 'INVALID_RECEIPT_KIND' };
    }
    if (!VALID_OPS.has(denial.op)) {
        return { valid: false, reason: 'INVALID_OP' };
    }
    if (!denial.agent_id || !denial.signer || !denial.reason) {
        return { valid: false, reason: 'MISSING_REQUIRED_FIELD' };
    }
    if (options.expected_signer && denial.signer !== options.expected_signer) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'signer mismatch' };
    }
    const issuedMs = Date.parse(denial.issued_at);
    if (!Number.isFinite(issuedMs)) {
        return { valid: false, reason: 'MISSING_REQUIRED_FIELD', detail: 'issued_at' };
    }
    const nowMs = (options.now ?? new Date()).getTime();
    if (nowMs - issuedMs > ttl * 1000) {
        return { valid: false, reason: 'EXPIRED' };
    }
    // mapping invariant: stored fields must match deterministic apsToAcpError
    const expected = apsToAcpError(denial.reason);
    if (denial.acp_error_code !== expected.code || denial.acp_error_type !== expected.type) {
        return {
            valid: false,
            reason: 'SIGNATURE_INVALID',
            detail: 'denial mapping mismatch (tampered or version drift)',
        };
    }
    const { signature, ...rest } = denial;
    const sigBytes = canonicalizeJCS(rest);
    if (typeof denial.signer === 'string' && denial.signer.startsWith('did:')) {
        return {
            valid: false,
            reason: 'DID_RESOLVER_MISSING',
            detail: 'use verifyAcpDenialWithDID for DID-URI signers',
        };
    }
    if (!edVerify(sigBytes, signature, denial.signer)) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'Ed25519 verify failed' };
    }
    return { valid: true };
}
/**
 * Phase 4.1 / P12: async denial verifier with DID URI support.
 */
export async function verifyAcpDenialWithDID(denial, options = {}) {
    const sync = verifyAcpDenial(denial, options);
    if (sync.valid)
        return sync;
    if (sync.reason !== 'DID_RESOLVER_MISSING')
        return sync;
    if (!options.resolveDidDocument) {
        return { valid: false, reason: 'DID_RESOLVER_MISSING' };
    }
    const parsed = parseDidUri(denial.signer);
    if (!parsed)
        return { valid: false, reason: 'DID_URI_INVALID' };
    const didDoc = await options.resolveDidDocument(parsed.agentId);
    if (!didDoc)
        return { valid: false, reason: 'DID_DOC_NOT_FOUND' };
    const issuedMs = Date.parse(denial.issued_at);
    const result = resolveVerificationMethod(didDoc, denial.signer, options.now ? options.now.getTime() : undefined, Number.isFinite(issuedMs) ? issuedMs : undefined);
    if (!result)
        return { valid: false, reason: 'DID_KEY_NOT_IN_DOC' };
    if (result.retired)
        return { valid: false, reason: 'DID_KEY_RETIRED' };
    const pubHex = publicKeyHexFromMethod(result.method);
    const { signature, ...rest } = denial;
    const sigBytes = canonicalizeJCS(rest);
    if (!edVerify(sigBytes, signature, pubHex)) {
        return { valid: false, reason: 'SIGNATURE_INVALID', detail: 'Ed25519 verify failed' };
    }
    return { valid: true };
}
//# sourceMappingURL=index.js.map