From f0f8ef9c0993bb2c60a4256e0e29549277e5420d Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 12 Aug 2026 05:04:32 -0700 Subject: [PATCH 1/2] Surface the pairwise operator handle through the Gate Merchants keying durable state on identity (prepaid balances first) need a value that outlives a credential. An opc_ lives 24h and rotates silently off a 90-day refresh, so state keyed on the token instance is stranded daily and revoking a leaked token would forfeit the balance it held. The handle derives from the account instead, and is pairwise per merchant so handles never correlate across stores. It rides the /v1/assess response the gate already fetches, so reading it is a synchronous cache read like getSignerVerdict, costs no second round trip on a merchant's hot path, and meters nothing extra. An earlier draft resolved it lazily through a separate endpoint; that was dropped because it doubled calls on a metered path and would have shipped every merchant an unmetered endpoint. Available on the DENY branch too, unlike `data`: this is identity rather than a verdict, and a merchant recording a denial against the buyer needs it on exactly the path where its handler never runs. All five adapters plus Checkout's ctx. Anything that is not a well-formed oph_ string reads as absent rather than passing through, so a half-configured API cannot hand a merchant a value it would write balance rows against. --- CLAUDE.md | 12 +++ src/checkout.ts | 16 ++++ src/core.ts | 56 +++++++++++++- src/identity/express.ts | 34 +++++++- src/identity/fastify.ts | 34 +++++++- src/identity/hono.ts | 36 ++++++++- src/identity/nextjs.ts | 9 ++- src/identity/web.ts | 17 ++++ tests/operator_handle.test.ts | 141 ++++++++++++++++++++++++++++++++++ 9 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 tests/operator_handle.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index ddefeff..79edb43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,18 @@ Denial reason codes: `missing_identity`, `identity_verification_required`, `toke `getSignerVerdict(ctx)` (per-adapter) returns the cached `signer_match` + `signer_sanctions` verdicts the gate composed on its primary `/v1/assess` call (single round trip; merchants build a 403 with `buildSignerMismatchBody({ result: verdict.signer_match })` when `kind !== 'pass'`). +### Operator handle: what durable merchant state keys on + +`getOperatorHandle(ctx)` (per-adapter; `gate.operatorHandle` on the Next.js / Web Fetch wrapper surface, `ctx.operatorHandle` inside `Checkout` hooks) returns the stable pairwise `oph_...` handle for the ACCOUNT behind the request's operator token. + +**Key state on this, never on the token.** An `opc_` lives 24h and rotates silently off a 90-day refresh, so anything keyed on the token instance is stranded daily, and revoking a leaked token would forfeit a prepaid balance. The handle derives from the account, so rotation, expiry and revocation are all free. It is pairwise per consuming merchant, so the same buyer presents an unrelated handle at every store and handles never correlate across them. + +It rides the gate's existing `/v1/assess` response, so reading it costs **no extra round trip and nothing extra against the merchant's quota**. That is why the accessor is synchronous like `getSignerVerdict` rather than doing a lookup of its own. + +Returns `undefined` when the gate did not run, on wallet-authenticated paths (there is no operator token to resolve), or when the API has no handle salt configured. Available on **denied** requests too, unlike `data`, so a merchant recording a denial against a buyer can still key it. It carries no compliance meaning: a registration-only (`sign_in`) credential resolves exactly like a KYC-backed one, so read the decision fields for policy. + +Anything that is not a well-formed `oph_` string reads as absent rather than being passed through, so a half-configured API can never hand a merchant a value it would write balance rows against. + Captured wallets: `captureWallet(ctx, { walletAddress, network, idempotencyKey })` is fire-and-forget; reads `operator_token` stashed during gating and POSTs to `/v1/credentials/wallets`. No-ops for wallet-authenticated requests. Wallet-signer-match + signer-sanctions: the gate adapter calls `extractPaymentSigner(request, x402PaymentHeader)` pre-evaluate (covers x402 EIP-3009 `from`, Tempo MPP `did:pkh:eip155` source, Solana MPP `did:pkh:solana` source, plus a Solana `TransferChecked` authority fallback decoded from the credential's signed-tx payload via the optional `@solana/kit` peer) and passes `signer: { address, network }` to the SDK's `assess`. The API returns both `signer_match` (wallet-binding) and `signer_sanctions` (OFAC SDN wallet-address) on the same response; commerce caches both projected verdicts so `getSignerVerdict` is a pure cache read. **Wallet-OFAC SDN enforcement on the `signer` block is unconditional** whenever a signer is present — no `policy.require_sanctions_clear` opt-in required. An SDN hit (or `sanctions_check_unavailable`) flips `decision -> deny` and the gate returns 403 before the handler runs. diff --git a/src/checkout.ts b/src/checkout.ts index 76340c1..a22400e 100644 --- a/src/checkout.ts +++ b/src/checkout.ts @@ -55,6 +55,7 @@ import { type CreateSessionOnMissing, type DenialReason, type EvaluateOutcome, + type OperatorHandle, createAgentScoreCore, } from './core'; import { enrichBazaarDiscoveryExtensions } from './discovery/bazaar'; @@ -277,6 +278,17 @@ export interface CheckoutContext { network: 'evm' | 'solana'; idempotencyKey?: string; }) => Promise; + /** Stable pairwise handle for the ACCOUNT behind this request's operator token, set by + * Checkout's internal gate from the same `/v1/assess` response it already fetched (so it + * costs no extra round trip and nothing extra against quota). + * + * This is what durable merchant state should key on, prepaid balances above all: it + * survives the token rotating, expiring or being revoked, whereas state keyed on the + * token instance is stranded every time one rotates. + * + * `undefined` when no gate is configured, on wallet or AIT paths, on anonymous discovery + * legs, or when the API has no handle salt configured. */ + operatorHandle?: OperatorHandle; } /** @@ -1486,6 +1498,10 @@ export class Checkout { const signer = await extractPaymentSignerFromAuth(headers['authorization'], x402Header); const outcome: EvaluateOutcome = await core.evaluate(identity, ctx, signer); + // Identity, not a verdict: stash it on both branches so a merchant recording a denial + // against the buyer can still key it. + if (outcome.operatorHandle !== undefined) ctx.operatorHandle = outcome.operatorHandle; + if (outcome.kind === 'allow') { // Stash captureWallet on ctx so onSettled can link the signer wallet to // the operator credential without needing a framework-specific Context. diff --git a/src/core.ts b/src/core.ts index 4db3d3a..7c99fd2 100644 --- a/src/core.ts +++ b/src/core.ts @@ -237,6 +237,11 @@ export interface AssessResult { * by the API. Useful for advertising in 402 challenges so wallet-auth agents know which * alt-signers will satisfy `wallet_signer_mismatch`. */ linked_wallets?: string[]; + /** Stable pairwise handle for the ACCOUNT behind the presented operator token. See + * {@link OperatorHandle}; read it through each adapter's `getOperatorHandle(ctx)`. + * Present only on the operator-token path, and only when the API has its handle salt + * configured. */ + operator_handle?: string; verify_url?: string; policy_result?: PolicyResult | null; /** IdP provenance, present only when `identity_method === 'aip_token'` — which issuer attested @@ -293,8 +298,11 @@ export interface GateQuotaInfo { * reason, or invoke the caller's custom denial handler. */ export type EvaluateOutcome = - | { kind: 'allow'; data?: AssessResult; degraded?: boolean; infraReason?: FailOpenInfraReason; quota?: GateQuotaInfo; signerVerdict?: SignerVerdict } - | { kind: 'deny'; reason: DenialReason; signerVerdict?: SignerVerdict }; + | { kind: 'allow'; data?: AssessResult; degraded?: boolean; infraReason?: FailOpenInfraReason; quota?: GateQuotaInfo; signerVerdict?: SignerVerdict; operatorHandle?: OperatorHandle } + // `operatorHandle` rides the DENY branch too, unlike `data`: it is identity rather than + // compliance, and a merchant recording a denial against the buyer needs it on exactly the + // path where the handler never runs. + | { kind: 'deny'; reason: DenialReason; signerVerdict?: SignerVerdict; operatorHandle?: OperatorHandle }; interface CaptureWalletOptions { /** Operator credential (`opc_...`) that the agent authenticated with. */ @@ -328,6 +336,26 @@ export interface SignerVerdict { | null; } +/** A stable, per-merchant identity for the ACCOUNT behind an operator token, surfaced by + * `getOperatorHandle(c)`. This is what durable merchant state (prepaid balances first) + * keys on. + * + * Two properties make it the right key, and both are the reason a raw token is the wrong + * one. It is stable across token ROTATION and expiry, because it derives from the account + * rather than the credential: an `opc_` lives 24h and rotates silently off a 90-day + * refresh, so state keyed on a token instance is stranded daily, and revoking a leaked + * token would forfeit a balance. And it is PAIRWISE per consuming merchant, so the same + * buyer presents an unrelated handle at every store and handles never correlate across + * merchants. + * + * It carries NO compliance meaning by design: a `sign_in`-scoped credential resolves + * exactly like a KYC-backed one, and every policy question stays on the decision fields + * beside it. + * + * It rides the gate's existing `/v1/assess` call rather than a lookup of its own, so + * reading it costs no extra round trip and nothing extra against the merchant's quota. */ +export type OperatorHandle = string; + export type VerifyWalletSignerResult = | { kind: 'pass'; claimedOperator: string | null; signerOperator: string | null } | { @@ -707,6 +735,9 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore const cachedVerdict = cached.raw ? buildSignerVerdict(identity, cached.raw as Record) : undefined; + const cachedHandle = cached.raw + ? readOperatorHandle(cached.raw as Record) + : undefined; if (cached.allow) { const cachedRaw = cached.raw as Record | undefined; const cachedQuota = cachedRaw?.quota as GateQuotaInfo | undefined; @@ -715,6 +746,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore data: cachedRaw as unknown as AssessResult, ...(cachedQuota !== undefined && { quota: cachedQuota }), ...(cachedVerdict !== undefined && { signerVerdict: cachedVerdict }), + ...(cachedHandle !== undefined && { operatorHandle: cachedHandle }), }; } // Fixable compliance denials (kyc_required, kyc_pending, kyc_failed) get the @@ -867,6 +899,8 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore // stash them on its per-request state (NOT a shared slot — see buildSignerVerdict). guard: // wallet-only identity (operator-token / AIT win and signer-match is deliberately not enforced). const signerVerdict = buildSignerVerdict(identity, data); + // Rides the same assess response, so reading it later costs nothing extra. + const operatorHandle = readOperatorHandle(data); if (allow) { // SDK populates `quota` on the assess response from X-Quota-* headers when the @@ -877,6 +911,7 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore data: data as unknown as AssessResult, ...(quota !== undefined && { quota }), ...(signerVerdict !== undefined && { signerVerdict }), + ...(operatorHandle !== undefined && { operatorHandle }), }; } @@ -890,7 +925,12 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore if (isFixableDenial(decisionReasons)) { const sessionReason = await tryMintSessionDenial(ctx); if (sessionReason) { - return { kind: 'deny', reason: sessionReason, ...(signerVerdict !== undefined && { signerVerdict }) }; + return { + kind: 'deny', + reason: sessionReason, + ...(signerVerdict !== undefined && { signerVerdict }), + ...(operatorHandle !== undefined && { operatorHandle }), + }; } } @@ -904,9 +944,19 @@ export function createAgentScoreCore(options: AgentScoreCoreOptions): AgentScore data: data as unknown as AssessResult, }, ...(signerVerdict !== undefined && { signerVerdict }), + ...(operatorHandle !== undefined && { operatorHandle }), }; } + // Project the handle off the assess response. Narrowed rather than cast: the field is + // absent whenever the request had no operator token, and absent (not empty-string) is + // also what the API emits if its handle salt is unconfigured, so anything that is not a + // usable `oph_` string must read as "no handle" instead of becoming a state key. + function readOperatorHandle(raw: Record | undefined): OperatorHandle | undefined { + const value = raw?.operator_handle; + return typeof value === 'string' && value.startsWith('oph_') ? value : undefined; + } + async function captureWallet(options: CaptureWalletOptions): Promise { try { await sdk.associateWallet({ diff --git a/src/identity/express.ts b/src/identity/express.ts index b8cdaaa..284c9e4 100644 --- a/src/identity/express.ts +++ b/src/identity/express.ts @@ -15,6 +15,7 @@ import type { DenialReason, FailOpenInfraReason, GateQuotaInfo, + OperatorHandle, SignerVerdict, } from '../core'; import type { Request, Response, NextFunction } from 'express'; @@ -39,6 +40,10 @@ interface GateState { * same-wallet/different-signer requests can't read each other's verdict. Read via * {@link getSignerVerdict}. */ signerVerdict?: SignerVerdict; + /** Stable pairwise handle for the account behind this request's operator token, + * projected off the same assess response as {@link signerVerdict}. Read via + * {@link getOperatorHandle}. */ + operatorHandle?: OperatorHandle; } interface AgentScoreGateOptions extends Omit { @@ -102,15 +107,21 @@ export function agentscoreGate(options: AgentScoreGateOptions) { } if (outcome.quota) state.quota = outcome.quota; if (outcome.signerVerdict) state.signerVerdict = outcome.signerVerdict; + if (outcome.operatorHandle) state.operatorHandle = outcome.operatorHandle; } if (outcome.data) (req as unknown as Record).agentscore = outcome.data; next(); return; } - if (outcome.signerVerdict) { + // Stash on the DENY path too: a merchant recording a denial against the buyer needs the + // handle on exactly the path where its handler never runs. + if (outcome.signerVerdict || outcome.operatorHandle) { const state = (req as unknown as Record)[GATE_STATE_KEY]; - if (state) state.signerVerdict = outcome.signerVerdict; + if (state) { + if (outcome.signerVerdict) state.signerVerdict = outcome.signerVerdict; + if (outcome.operatorHandle) state.operatorHandle = outcome.operatorHandle; + } } onDenied(req, res, outcome.reason); }; @@ -183,6 +194,7 @@ export function getSignerVerdict(req: Request): SignerVerdict | undefined { return state?.signerVerdict; } + /** Wrap `agentscoreGate(...)` so it only fires when a payment credential is * attached to the request. Discovery legs (no payment header) flow through * unauthenticated and the handler emits a 402 with all rails; settle legs @@ -261,3 +273,21 @@ export function conditionalAipGate(options: AipGateExpressOptions) { export function getVerifiedAit(req: Request): VerifiedAit | undefined { return (req as unknown as Record)[AIT_STATE_KEY]; } + +/** + * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's + * operator token. This is what durable merchant state (prepaid balances first) should key + * on, because it survives the token rotating, expiring, or being revoked, whereas anything + * keyed on the token instance is stranded every time one rotates. + * + * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading + * it costs no extra round trip and nothing extra against the merchant's quota. + * + * Returns `undefined` when the gate did not run, no operator token was presented (wallet or + * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * so a merchant recording a denial against a buyer can still key it. + */ +export function getOperatorHandle(req: Request): OperatorHandle | undefined { + const state = (req as unknown as Record)[GATE_STATE_KEY]; + return state?.operatorHandle; +} diff --git a/src/identity/fastify.ts b/src/identity/fastify.ts index 47ab063..f348177 100644 --- a/src/identity/fastify.ts +++ b/src/identity/fastify.ts @@ -15,6 +15,7 @@ import type { DenialReason, FailOpenInfraReason, GateQuotaInfo, + OperatorHandle, SignerVerdict, } from '../core'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; @@ -37,6 +38,10 @@ interface GateState { * same-wallet/different-signer requests can't read each other's verdict. Read via * {@link getSignerVerdict}. */ signerVerdict?: SignerVerdict; + /** Stable pairwise handle for the account behind this request's operator token, + * projected off the same assess response as {@link signerVerdict}. Read via + * {@link getOperatorHandle}. */ + operatorHandle?: OperatorHandle; } interface AgentScoreGateOptions extends Omit { @@ -114,14 +119,20 @@ const agentscoreGatePlugin: FastifyPluginAsync = async (f } if (outcome.quota) state.quota = outcome.quota; if (outcome.signerVerdict) state.signerVerdict = outcome.signerVerdict; + if (outcome.operatorHandle) state.operatorHandle = outcome.operatorHandle; } if (outcome.data) (request as unknown as Record).agentscore = outcome.data; return; } - if (outcome.signerVerdict) { + // Stash on the DENY path too: a merchant recording a denial against the buyer needs the + // handle on exactly the path where its handler never runs. + if (outcome.signerVerdict || outcome.operatorHandle) { const state = (request as unknown as Record)[GATE_STATE_KEY]; - if (state) state.signerVerdict = outcome.signerVerdict; + if (state) { + if (outcome.signerVerdict) state.signerVerdict = outcome.signerVerdict; + if (outcome.operatorHandle) state.operatorHandle = outcome.operatorHandle; + } } await onDenied(request, reply, outcome.reason); }); @@ -191,6 +202,7 @@ export function getSignerVerdict(request: FastifyRequest): SignerVerdict | undef return state?.signerVerdict; } + // Escape Fastify's plugin encapsulation so the preHandler hook applies to routes // registered at the parent scope (the common case: `app.register(agentscoreGate, ...)` // followed by `app.get(...)` at the root). Equivalent to fastify-plugin without the @@ -314,3 +326,21 @@ export const conditionalAipGate = conditionalAipGatePlugin; export function getVerifiedAit(request: FastifyRequest): VerifiedAit | undefined { return (request as unknown as Record)[AIT_STATE_KEY]; } + +/** + * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's + * operator token. This is what durable merchant state (prepaid balances first) should key + * on, because it survives the token rotating, expiring, or being revoked, whereas anything + * keyed on the token instance is stranded every time one rotates. + * + * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading + * it costs no extra round trip and nothing extra against the merchant's quota. + * + * Returns `undefined` when the gate did not run, no operator token was presented (wallet or + * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * so a merchant recording a denial against a buyer can still key it. + */ +export function getOperatorHandle(request: FastifyRequest): OperatorHandle | undefined { + const state = (request as unknown as Record)[GATE_STATE_KEY]; + return state?.operatorHandle; +} diff --git a/src/identity/hono.ts b/src/identity/hono.ts index 8666304..f19e735 100644 --- a/src/identity/hono.ts +++ b/src/identity/hono.ts @@ -15,6 +15,7 @@ import type { DenialReason, FailOpenInfraReason, GateQuotaInfo, + OperatorHandle, SignerVerdict, } from '../core'; import type { Context, MiddlewareHandler } from 'hono'; @@ -41,6 +42,10 @@ interface GateState { * same-wallet/different-signer requests can't read each other's verdict. Read via * {@link getSignerVerdict}. */ signerVerdict?: SignerVerdict; + /** Stable pairwise handle for the account behind this request's operator token, + * projected off the same assess response as {@link signerVerdict}. Read via + * {@link getOperatorHandle}. */ + operatorHandle?: OperatorHandle; } interface AgentScoreGateOptions extends Omit { @@ -100,13 +105,14 @@ export function agentscoreGate(options: AgentScoreGateOptions): MiddlewareHandle const outcome = await core.evaluate(identity, c, signer); if (outcome.kind === 'allow') { - if (outcome.degraded || outcome.quota || outcome.signerVerdict) { + if (outcome.degraded || outcome.quota || outcome.signerVerdict || outcome.operatorHandle) { const prev = c.get(GATE_STATE_KEY) as GateState; c.set(GATE_STATE_KEY, { ...prev, ...(outcome.degraded && { degraded: true, infraReason: outcome.infraReason }), ...(outcome.quota && { quota: outcome.quota }), ...(outcome.signerVerdict && { signerVerdict: outcome.signerVerdict }), + ...(outcome.operatorHandle && { operatorHandle: outcome.operatorHandle }), } satisfies GateState); } if (outcome.data) c.set(CONTEXT_KEY, outcome.data); @@ -114,9 +120,15 @@ export function agentscoreGate(options: AgentScoreGateOptions): MiddlewareHandle return; } - if (outcome.signerVerdict) { + // Stash on the DENY path too: a merchant recording a denial against the buyer needs the + // handle on exactly the path where its handler never runs. + if (outcome.signerVerdict || outcome.operatorHandle) { const prev = c.get(GATE_STATE_KEY) as GateState; - c.set(GATE_STATE_KEY, { ...prev, signerVerdict: outcome.signerVerdict } satisfies GateState); + c.set(GATE_STATE_KEY, { + ...prev, + ...(outcome.signerVerdict && { signerVerdict: outcome.signerVerdict }), + ...(outcome.operatorHandle && { operatorHandle: outcome.operatorHandle }), + } satisfies GateState); } return onDenied(c, outcome.reason); }; @@ -204,6 +216,7 @@ export function getSignerVerdict(c: Context): SignerVerdict | undefined { } + /** Wrap `agentscoreGate(...)` so it only fires when a payment credential is * attached to the request. Discovery legs (no payment header) flow through * unauthenticated and the handler emits a 402 with all rails; settle legs @@ -288,3 +301,20 @@ export function getVerifiedAit(c: Context): VerifiedAit | undefined { return c.get(AIT_CONTEXT_KEY) as VerifiedAit | undefined; } +/** + * Read the stable pairwise {@link OperatorHandle} for the ACCOUNT behind this request's + * operator token. This is what durable merchant state (prepaid balances first) should key + * on, because it survives the token rotating, expiring, or being revoked, whereas anything + * keyed on the token instance is stranded every time one rotates. + * + * Synchronous and free: the handle rides the gate's existing `/v1/assess` call, so reading + * it costs no extra round trip and nothing extra against the merchant's quota. + * + * Returns `undefined` when the gate did not run, no operator token was presented (wallet or + * AIT paths), or the API has no handle salt configured. Available on denied requests too, + * so a merchant recording a denial against a buyer can still key it. + */ +export function getOperatorHandle(c: Context): OperatorHandle | undefined { + const state = c.get(GATE_STATE_KEY) as GateState | undefined; + return state?.operatorHandle; +} diff --git a/src/identity/nextjs.ts b/src/identity/nextjs.ts index 8c59802..9d1cd1e 100644 --- a/src/identity/nextjs.ts +++ b/src/identity/nextjs.ts @@ -1,6 +1,6 @@ import { hasPaymentHeader } from '../payment/payment_header'; import { createAgentScoreGate } from './web'; -import type { AssessResult, FailOpenInfraReason, GateQuotaInfo, SignerVerdict } from '../core'; +import type { AssessResult, FailOpenInfraReason, GateQuotaInfo, OperatorHandle, SignerVerdict } from '../core'; /** @@ -40,6 +40,12 @@ export function withAgentScoreGate SignerVerdict | undefined; + /** Stable pairwise handle for the ACCOUNT behind this request's operator token, and + * what durable merchant state (prepaid balances first) should key on: it survives + * the token rotating, expiring or being revoked. Rides the gate's existing + * `/v1/assess` call, so it costs no extra round trip and nothing extra against + * quota. `undefined` on wallet or AIT paths, or when the API has no handle salt. */ + operatorHandle?: OperatorHandle; /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure * (429/5xx/network timeout). Compliance was NOT enforced — log/alert in your handler. */ degraded?: boolean; @@ -61,6 +67,7 @@ export function withAgentScoreGate SignerVerdict | undefined; + /** Stable pairwise handle for the ACCOUNT behind this request's operator token, and + * what durable merchant state (prepaid balances first) should key on: it survives + * the token rotating, expiring or being revoked, whereas token-keyed state is + * stranded on every rotation. Rides the gate's existing `/v1/assess` call, so it + * costs no extra round trip and nothing extra against quota. `undefined` on wallet + * or AIT paths, or when the API has no handle salt configured. */ + operatorHandle?: OperatorHandle; /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure * (429/5xx/network timeout). Compliance was NOT enforced this request — log/alert. */ degraded?: boolean; @@ -125,6 +133,7 @@ export function createAgentScoreGate(options: AgentScoreGateOptions): (req: Requ data: outcome.data, captureWallet, getSignerVerdict: getSignerVerdictBound, + ...(outcome.operatorHandle ? { operatorHandle: outcome.operatorHandle } : {}), ...(outcome.degraded ? { degraded: true, infraReason: outcome.infraReason } : {}), ...(outcome.quota ? { quota: outcome.quota } : {}), }; @@ -160,6 +169,13 @@ export function withAgentScoreGate( /** Synchronous read of the cached signer verdicts. See {@link GuardResult}'s * `getSignerVerdict` for the contract. */ getSignerVerdict?: () => SignerVerdict | undefined; + /** Stable pairwise handle for the ACCOUNT behind this request's operator token, and + * what durable merchant state (prepaid balances first) should key on: it survives + * the token rotating, expiring or being revoked, whereas token-keyed state is + * stranded on every rotation. Rides the gate's existing `/v1/assess` call, so it + * costs no extra round trip and nothing extra against quota. `undefined` on wallet + * or AIT paths, or when the API has no handle salt configured. */ + operatorHandle?: OperatorHandle; /** Set to `true` only when the gate fail-open'd due to AgentScore-side infra failure * (429/5xx/network timeout). Compliance was NOT enforced this request — log/alert. */ degraded?: boolean; @@ -181,6 +197,7 @@ export function withAgentScoreGate( data: result.data, captureWallet: result.captureWallet, getSignerVerdict: result.getSignerVerdict, + ...(result.operatorHandle ? { operatorHandle: result.operatorHandle } : {}), ...(result.degraded ? { degraded: true, infraReason: result.infraReason } : {}), ...(result.quota ? { quota: result.quota } : {}), }, diff --git a/tests/operator_handle.test.ts b/tests/operator_handle.test.ts new file mode 100644 index 0000000..c01544d --- /dev/null +++ b/tests/operator_handle.test.ts @@ -0,0 +1,141 @@ +/** + * The operator handle: the identity durable merchant state keys on. + * + * The whole reason it exists is that an `opc_` token is the WRONG key. It lives 24h and + * rotates silently off a 90-day refresh, so anything keyed on the token instance is + * stranded daily, and revoking a leaked token would forfeit a prepaid balance. The handle + * derives from the account behind the token instead. + * + * It rides the gate's existing `/v1/assess` response rather than a lookup of its own, so + * these tests pin the two properties that follow from that choice and would be easy to lose + * in a refactor: the gate makes NO second call to get it, and it survives on the deny path + * where the handler never runs. + */ + +import { Hono } from 'hono'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { agentscoreGate, getOperatorHandle } from '../src/identity/hono'; + +const HANDLE = `oph_${'a'.repeat(40)}`; + +/** Minimal /v1/assess double. Returns the real platform shape and counts calls, because + * "no extra round trip" is the claim most worth holding still. */ +function mockAssess(body: Record, status = 200) { + const calls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => { + calls.push(String(input)); + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); + })); + return calls; +} + +const gatedApp = () => { + const app = new Hono(); + app.use('/buy', agentscoreGate({ apiKey: 'as_test_key' })); + app.post('/buy', (c) => c.json({ handle: getOperatorHandle(c) ?? null })); + return app; +}; + +const post = (app: Hono, headers: Record = {}) => + app.request('/buy', { method: 'POST', headers }); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('getOperatorHandle', () => { + it('surfaces the handle from the assess response with NO second API call', async () => { + const calls = mockAssess({ + decision: 'allow', + decision_reasons: [], + identity_method: 'operator_token', + operator_handle: HANDLE, + }); + + const res = await post(gatedApp(), { 'x-operator-token': 'opc_live_token' }); + expect(await res.json()).toEqual({ handle: HANDLE }); + + // The point of folding the handle into assess: exactly one call, and it is assess. + // A second entry here means someone reintroduced a resolve round trip, which is both + // latency on a hot path and an unmetered call the merchant never asked for. + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('/v1/assess'); + }); + + it('is available on the DENY path, where the handler never runs', async () => { + // A merchant recording a denial against a buyer needs the key on exactly this path. + mockAssess({ + decision: 'deny', + decision_reasons: ['sanctions_flagged'], + identity_method: 'operator_token', + operator_handle: HANDLE, + }); + + const app = new Hono(); + let seen: string | undefined | null = 'unset'; + app.use('/buy', agentscoreGate({ + apiKey: 'as_test_key', + requireSanctionsClear: true, + onDenied: (c) => { + seen = getOperatorHandle(c); + return c.json({ denied: true }, 403); + }, + })); + app.post('/buy', (c) => c.json({ ok: true })); + + const res = await post(app, { 'x-operator-token': 'opc_live_token' }); + expect(res.status).toBe(403); + expect(seen).toBe(HANDLE); + }); + + it('survives the gate cache, so a second request still keys state correctly', async () => { + // The cached branch recomputes projections from the stored raw response. Forgetting the + // handle there would strand every request after the first with no key, which reads as + // an anonymous buyer rather than as a bug. + const calls = mockAssess({ + decision: 'allow', + decision_reasons: [], + identity_method: 'operator_token', + operator_handle: HANDLE, + }); + const app = gatedApp(); + const headers = { 'x-operator-token': 'opc_live_token' }; + + expect(await (await post(app, headers)).json()).toEqual({ handle: HANDLE }); + expect(await (await post(app, headers)).json()).toEqual({ handle: HANDLE }); + expect(calls).toHaveLength(1); // second request served from cache + }); + + it('is undefined on the wallet path and when the API omits it', async () => { + // Wallet-authenticated requests have no operator token, so there is no account handle + // to mint. Undefined must mean "no handle", never an empty-ish key a merchant could + // accidentally write rows against. + mockAssess({ decision: 'allow', decision_reasons: [], identity_method: 'wallet' }); + const res = await post(gatedApp(), { 'x-wallet-address': '0x' + '1'.repeat(40) }); + expect(await res.json()).toEqual({ handle: null }); + }); + + it('ignores a malformed handle rather than keying state on it', async () => { + // An unsalted or half-configured API must not be able to hand a merchant a value that + // looks usable. Anything that is not an `oph_` string reads as absent. + mockAssess({ + decision: 'allow', + decision_reasons: [], + identity_method: 'operator_token', + operator_handle: '', + }); + const res = await post(gatedApp(), { 'x-operator-token': 'opc_live_token' }); + expect(await res.json()).toEqual({ handle: null }); + }); + + it('returns undefined when the gate never ran on the route', async () => { + const app = new Hono(); + app.post('/ungated', (c) => c.json({ handle: getOperatorHandle(c) ?? null })); + const res = await app.request('/ungated', { method: 'POST' }); + expect(await res.json()).toEqual({ handle: null }); + }); +}); From 505760696fe6347fa8f8108741309174688b3579 Mon Sep 17 00:00:00 2001 From: vvillait88 Date: Wed, 12 Aug 2026 05:09:29 -0700 Subject: [PATCH 2/2] Bump to 2.8.0 Minor rather than patch: this adds exported surface (getOperatorHandle on every adapter, ctx.operatorHandle on Checkout, the OperatorHandle type) rather than only changing behavior. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 1e62344..2230903 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@agent-score/commerce", - "version": "2.7.12", + "version": "2.8.0", "description": "Agentic commerce SDK: identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agentic commerce.", "main": "./dist/index.js", "module": "./dist/index.mjs",