@visa/cli 4.1.0-rc.37 → 4.1.0-rc.39
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -36
- package/dist/checkout-engine/cli-engine.d.ts +13 -26
- package/dist/checkout-engine/cli-engine.js +189 -116
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +3 -1
- package/dist/checkout-engine/executor.js +14 -14
- package/dist/checkout-engine/hosted-approval.d.ts +7 -0
- package/dist/checkout-engine/hosted-approval.js +29 -1
- package/dist/checkout-engine/live-fill-approval.js +3 -3
- package/dist/checkout-engine/mandate/card-mandate.d.ts +5 -1
- package/dist/checkout-engine/mandate/card-mandate.js +7 -1
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +13 -12
- package/dist/checkout-engine/mandate/mandate-ledger.js +15 -4
- package/dist/checkout-engine/run-live-fill.js +6 -5
- package/dist/checkout-engine/vic-confirmation.js +2 -2
- package/dist/cli.js +373 -335
- package/dist/mcp-server/index.js +300 -262
- package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -79
- package/dist/skills/pair-visa-agent/SKILL.md +197 -311
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +2 -2
- package/server.json +3 -3
|
@@ -18,7 +18,7 @@ import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realS
|
|
|
18
18
|
import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
19
19
|
import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
20
20
|
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
|
-
import { createCardMandate, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
21
|
+
import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
22
22
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
23
23
|
import { buildReceipt, writeReceipt as realWriteReceipt } from './receipt.js';
|
|
24
24
|
import { reportVicOutcome as realReportVicOutcome, } from './vic-confirmation.js';
|
|
@@ -46,6 +46,32 @@ export function isTransientDrawFailure(err) {
|
|
|
46
46
|
// card-decline reason) matches nothing here → the mandate is correctly disabled.
|
|
47
47
|
return /\(last:\s*(?:5\d\d|429)\b|\bETIMEDOUT\b|\bECONNRESET\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|socket hang up|tim(?:e|ed)[ -]?out/i.test(msgs.join(' '));
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* A verdict refusal may be wrapped by drawFromMandate after its local
|
|
51
|
+
* reservation is released. Walk the cause chain so the original auth status +
|
|
52
|
+
* reasons still decide whether the mandate is permanently disabled.
|
|
53
|
+
*/
|
|
54
|
+
function classifyCardDrawVerdictFailure(err) {
|
|
55
|
+
const transientReasons = new Set(['challenge_failed', 'challenge_malformed', 'verdict_refused']);
|
|
56
|
+
let current = err;
|
|
57
|
+
for (let i = 0; i < 5 && current; i++) {
|
|
58
|
+
const candidate = current;
|
|
59
|
+
const status = typeof candidate.status === 'number' ? candidate.status : 0;
|
|
60
|
+
const reasons = Array.isArray(candidate.reasons)
|
|
61
|
+
? candidate.reasons.filter((reason) => typeof reason === 'string')
|
|
62
|
+
: [];
|
|
63
|
+
if (status !== 0 || reasons.length > 0) {
|
|
64
|
+
return {
|
|
65
|
+
transient: status === 503 ||
|
|
66
|
+
reasons.length === 0 ||
|
|
67
|
+
reasons.every((reason) => transientReasons.has(reason)),
|
|
68
|
+
reasons,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
current = candidate.cause;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
49
75
|
const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
|
|
50
76
|
// Must match the prepared-checkout store TTL so a session and its store entry
|
|
51
77
|
// expire together — an abandoned review can't leak the browser + state.
|
|
@@ -66,6 +92,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
66
92
|
const ledger = deps.ledger ?? defaultLedger;
|
|
67
93
|
const now = deps.now ?? (() => new Date());
|
|
68
94
|
const fetchMandateCryptogram = deps.serverFetchCryptogram ?? serverFetchCryptogram;
|
|
95
|
+
const postServerConfirmation = deps.serverPostConfirmation ?? serverPostConfirmation;
|
|
69
96
|
const cardDrawVerdict = deps.cardDrawVerdict ?? null;
|
|
70
97
|
const cardMandateRegister = deps.cardMandateRegister ?? null;
|
|
71
98
|
async function closeSession(reviewId) {
|
|
@@ -95,6 +122,9 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
95
122
|
if (ceilingMinor === null || ceilingMinor <= 0) {
|
|
96
123
|
throw new Error(`invalid mandate ceiling ${JSON.stringify(input.ceiling)}`);
|
|
97
124
|
}
|
|
125
|
+
if (input.currency.toUpperCase() !== 'USD') {
|
|
126
|
+
throw new Error('card spend budgets currently support USD only');
|
|
127
|
+
}
|
|
98
128
|
if (input.perTransaction !== undefined) {
|
|
99
129
|
const perTxMinor = decimalToMinor(input.perTransaction);
|
|
100
130
|
if (perTxMinor === null || perTxMinor <= 0 || perTxMinor > ceilingMinor) {
|
|
@@ -102,24 +132,24 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
102
132
|
'must be a positive amount at or under the ceiling');
|
|
103
133
|
}
|
|
104
134
|
}
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
// A budget token can bootstrap one VGS intent and its register handshake,
|
|
136
|
+
// but it is never payable draw authority. Refuse before the passkey
|
|
137
|
+
// ceremony unless this runtime can both prove the separately provisioned
|
|
138
|
+
// card capability and register the resulting intent server-side.
|
|
139
|
+
const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
|
|
140
|
+
if (!cardMandateRegister || !registerCap) {
|
|
141
|
+
throw new Error('startCardMandate requires separately provisioned card authority in this runtime; ' +
|
|
142
|
+
'identity pairing alone does not grant a card mandate');
|
|
111
143
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
:
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
countryCode: input.merchantCountryCode ?? 'US',
|
|
122
|
-
};
|
|
144
|
+
// There is one budget product: eligible retail merchants under the
|
|
145
|
+
// provider's required Retail/5999 network category, with total,
|
|
146
|
+
// per-purchase, count, and time bounds stated on the approval page. The
|
|
147
|
+
// sentinel is provider metadata, never a user-entered merchant route.
|
|
148
|
+
const merchant = {
|
|
149
|
+
name: 'retail spend budget',
|
|
150
|
+
url: 'https://retail-budget.visa/budget',
|
|
151
|
+
countryCode: 'US',
|
|
152
|
+
};
|
|
123
153
|
const credential = JSON.parse(await readFile(input.credentialPath, 'utf8'));
|
|
124
154
|
// The passkey ceremony is scoped to the CEILING + merchant (not one
|
|
125
155
|
// charge) — that scope is the unproven part of the spike.
|
|
@@ -140,8 +170,9 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
140
170
|
target: ceilingTarget,
|
|
141
171
|
consumerEmail: input.contact.email,
|
|
142
172
|
budget: true,
|
|
173
|
+
agentJkt: registerCap.agentJkt,
|
|
143
174
|
onApprovalUrl: deps.onApprovalUrl,
|
|
144
|
-
|
|
175
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
145
176
|
...(input.perTransaction !== undefined ? { perTransaction: input.perTransaction } : {}),
|
|
146
177
|
...(input.intent !== undefined ? { intent: input.intent } : {}),
|
|
147
178
|
});
|
|
@@ -150,29 +181,30 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
150
181
|
throw new Error('the approval server issued no mint token — server-side minting requires the ' +
|
|
151
182
|
'verify-web deployment to run real/turnkey auth (not the dev stub). Retry once it does.');
|
|
152
183
|
}
|
|
153
|
-
|
|
184
|
+
if (!Number.isSafeInteger(assurance.validUntil) ||
|
|
185
|
+
assurance.validUntil <= Math.floor(now().getTime() / 1000)) {
|
|
186
|
+
throw new Error('the approval server issued no valid budget expiry');
|
|
187
|
+
}
|
|
188
|
+
const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
|
|
154
189
|
const facts = await createCardMandate({
|
|
190
|
+
agentJkt: registerCap.agentJkt,
|
|
155
191
|
tokenId: credential.tokenId,
|
|
156
192
|
assuranceData: assurance.assuranceData,
|
|
157
193
|
ceilingMinor,
|
|
158
194
|
merchant,
|
|
159
195
|
currencyCode: input.currency,
|
|
160
196
|
expiresAt,
|
|
161
|
-
|
|
162
|
-
|
|
197
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
198
|
+
crossMerchant: true,
|
|
163
199
|
}, {
|
|
164
200
|
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
165
201
|
ledger,
|
|
166
202
|
approvalBaseUrl: input.approvalBaseUrl,
|
|
167
|
-
mintToken,
|
|
168
203
|
now,
|
|
169
204
|
});
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
// path still works, only the delegated draw needs the register row. Skipped
|
|
174
|
-
// entirely when no mode='card' delegated binding is present.
|
|
175
|
-
const registerCap = cardMandateRegister?.loadCapability() ?? null;
|
|
205
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
206
|
+
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
207
|
+
// falls back as payable authority.
|
|
176
208
|
let registerFailed = false;
|
|
177
209
|
if (registerCap && cardMandateRegister) {
|
|
178
210
|
const reg = await cardMandateRegister
|
|
@@ -277,6 +309,53 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
277
309
|
remainingMinor: null,
|
|
278
310
|
};
|
|
279
311
|
}
|
|
312
|
+
// The reviewId selects the prepared browser session, but the pay call also
|
|
313
|
+
// repeats the target facts. Refuse if any repeated fact disagrees so the
|
|
314
|
+
// caller cannot submit one reviewed checkout while labeling the result or
|
|
315
|
+
// telemetry as another. Never echo the full URLs: payment links can carry
|
|
316
|
+
// claimable secrets in their path/query.
|
|
317
|
+
let payUrl;
|
|
318
|
+
let reviewedUrl;
|
|
319
|
+
try {
|
|
320
|
+
payUrl = new URL(input.url).toString();
|
|
321
|
+
reviewedUrl = new URL(session.target.merchantUrl).toString();
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
await closeSession(input.reviewId);
|
|
325
|
+
return {
|
|
326
|
+
outcome: 'failed',
|
|
327
|
+
confirmationRef: null,
|
|
328
|
+
receiptPath: null,
|
|
329
|
+
detail: 'pay merchant URL is invalid or does not match the reviewed checkout — start a fresh review',
|
|
330
|
+
vicConfirmation: null,
|
|
331
|
+
source: null,
|
|
332
|
+
remainingMinor: null,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
if (payUrl !== reviewedUrl) {
|
|
336
|
+
await closeSession(input.reviewId);
|
|
337
|
+
return {
|
|
338
|
+
outcome: 'failed',
|
|
339
|
+
confirmationRef: null,
|
|
340
|
+
receiptPath: null,
|
|
341
|
+
detail: 'pay merchant URL does not match the reviewed checkout — start a fresh review',
|
|
342
|
+
vicConfirmation: null,
|
|
343
|
+
source: null,
|
|
344
|
+
remainingMinor: null,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
if (input.currency.toUpperCase() !== session.currency.toUpperCase()) {
|
|
348
|
+
await closeSession(input.reviewId);
|
|
349
|
+
return {
|
|
350
|
+
outcome: 'failed',
|
|
351
|
+
confirmationRef: null,
|
|
352
|
+
receiptPath: null,
|
|
353
|
+
detail: `pay currency ${JSON.stringify(input.currency)} does not match the reviewed currency (${session.currency}) — start a fresh review`,
|
|
354
|
+
vicConfirmation: null,
|
|
355
|
+
source: null,
|
|
356
|
+
remainingMinor: null,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
280
359
|
// Amount-bind the confirmation: the pay-call amount must match the
|
|
281
360
|
// reviewed amount. The reviewId already locks the immutable mandate, but
|
|
282
361
|
// re-checking here makes the confirmation explicitly amount-bound so a
|
|
@@ -315,8 +394,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
315
394
|
const host = new URL(session.target.merchantUrl).hostname;
|
|
316
395
|
// Does an ACTIVE card mandate already cover this exact purchase? If so,
|
|
317
396
|
// draw against it TAP-FREE (no hosted passkey). Else fall back to today's
|
|
318
|
-
// 1:1 fresh-tap flow.
|
|
319
|
-
//
|
|
397
|
+
// 1:1 fresh-tap flow. Mandates no longer persist the bootstrap token:
|
|
398
|
+
// it is consumed by intent creation + register and has no draw power.
|
|
320
399
|
const covering = await ledger.findCovering({
|
|
321
400
|
merchantHost: host,
|
|
322
401
|
currencyCode: session.currency,
|
|
@@ -325,94 +404,50 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
325
404
|
});
|
|
326
405
|
let source;
|
|
327
406
|
let confirmBase;
|
|
328
|
-
|
|
407
|
+
// Fresh purchases confirm with their one-purchase mint token. Mandate
|
|
408
|
+
// draws set this only after the local reserve succeeds and auth returns
|
|
409
|
+
// the exact per-draw verdict used to mint the credential.
|
|
410
|
+
let confirmationAuthority = null;
|
|
329
411
|
let drawnRemaining = null;
|
|
330
412
|
let instrument;
|
|
331
|
-
if (covering
|
|
413
|
+
if (covering) {
|
|
332
414
|
// --- Tap-free mandate draw ------------------------------------------
|
|
333
415
|
source = 'mandate';
|
|
334
416
|
confirmBase = covering.approvalBaseUrl;
|
|
335
|
-
// The bearer mint token stays the auth for the VIC confirmation POST
|
|
336
|
-
// (the confirmation route accepts only the mint token). The CRYPTOGRAM
|
|
337
|
-
// mint, however, may use a delegated verdict token — see below.
|
|
338
|
-
confirmToken = covering.mintToken;
|
|
339
417
|
const mandateId = covering.mandateId;
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
// binding (or seam not injected) → `drawToken` stays the bearer token:
|
|
344
|
-
// today's #5917 draw, byte-for-byte unchanged (converge-not-break).
|
|
418
|
+
// A mandate cryptogram is authorized only by a PoP-signed verdict.
|
|
419
|
+
// The budget mint token is bootstrap-only and cannot bypass the
|
|
420
|
+
// server reserve/commit ledger when local draw authority is absent.
|
|
345
421
|
//
|
|
346
422
|
// #5928 CRITICAL-2: the CLI does NOT settle the reservation. verify-web
|
|
347
423
|
// (which knows whether the cryptogram was payable) commits it on a
|
|
348
424
|
// payable mint and releases it on a decline, server-authoritatively, so
|
|
349
425
|
// the drawer can never release after a payable mint to dodge the ceiling.
|
|
350
|
-
|
|
351
|
-
const capability =
|
|
352
|
-
if (capability
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
// escapes pay() raw — making an agent WITH a card binding strictly more
|
|
376
|
-
// fragile than one without (breaks converge-not-break). Convert it into
|
|
377
|
-
// a graceful FAILED result instead. We do NOT silently fall back to the
|
|
378
|
-
// bearer mint token: on an over_ceiling/reserve_refused refusal that
|
|
379
|
-
// would DODGE the server-side ceiling the verdict path exists to enforce.
|
|
380
|
-
//
|
|
381
|
-
// Definitive gate refusal (no_mandate, over_ceiling, reserve_refused,
|
|
382
|
-
// checkout_access_revoked, token_mismatch, …): the mandate cannot be
|
|
383
|
-
// drawn, so disable it — findCovering then skips it and the NEXT
|
|
384
|
-
// pay_merchant falls through to a fresh per-purchase tap. Transient
|
|
385
|
-
// (503 card_draw_disabled, challenge hiccup, network): leave the mandate
|
|
386
|
-
// healthy and surface a retryable message. Duck-typed: the engine cannot
|
|
387
|
-
// import CardDrawRefusedError (it lives in @visa/cli, above this seam).
|
|
388
|
-
const reasons = Array.isArray(err.reasons)
|
|
389
|
-
? err.reasons
|
|
390
|
-
: [];
|
|
391
|
-
const status = typeof err.status === 'number'
|
|
392
|
-
? err.status
|
|
393
|
-
: 0;
|
|
394
|
-
const TRANSIENT = new Set([
|
|
395
|
-
'challenge_failed',
|
|
396
|
-
'challenge_malformed',
|
|
397
|
-
'verdict_refused',
|
|
398
|
-
]);
|
|
399
|
-
const transient = status === 503 || reasons.length === 0 || reasons.every((r) => TRANSIENT.has(r));
|
|
400
|
-
if (!transient) {
|
|
401
|
-
// Best-effort: a mark failure must not turn a clean refusal into a throw.
|
|
402
|
-
await ledger.markUnhonored(mandateId, now()).catch(() => { });
|
|
403
|
-
}
|
|
404
|
-
return {
|
|
405
|
-
outcome: 'failed',
|
|
406
|
-
confirmationRef: null,
|
|
407
|
-
receiptPath: null,
|
|
408
|
-
detail: transient
|
|
409
|
-
? `the card-mandate draw could not be authorized right now (${reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
410
|
-
: `this card mandate can no longer be drawn (${reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase passkey tap`,
|
|
411
|
-
vicConfirmation: null,
|
|
412
|
-
source,
|
|
413
|
-
remainingMinor: null,
|
|
414
|
-
};
|
|
415
|
-
}
|
|
426
|
+
const verdictSeam = cardDrawVerdict;
|
|
427
|
+
const capability = verdictSeam?.loadCapability(covering.agentJkt) ?? null;
|
|
428
|
+
if (!capability || !verdictSeam) {
|
|
429
|
+
return {
|
|
430
|
+
outcome: 'failed',
|
|
431
|
+
confirmationRef: null,
|
|
432
|
+
receiptPath: null,
|
|
433
|
+
detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
|
|
434
|
+
'restore the runtime binding or use a fresh per-purchase passkey approval',
|
|
435
|
+
vicConfirmation: null,
|
|
436
|
+
source,
|
|
437
|
+
remainingMinor: null,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (covering.agentJkt && capability.agentJkt !== covering.agentJkt) {
|
|
441
|
+
return {
|
|
442
|
+
outcome: 'failed',
|
|
443
|
+
confirmationRef: null,
|
|
444
|
+
receiptPath: null,
|
|
445
|
+
detail: 'this budget belongs to a different request key than the selected card capability; ' +
|
|
446
|
+
'restore that exact runtime key or use a fresh per-purchase approval',
|
|
447
|
+
vicConfirmation: null,
|
|
448
|
+
source,
|
|
449
|
+
remainingMinor: null,
|
|
450
|
+
};
|
|
416
451
|
}
|
|
417
452
|
// VgsLiveInstrument mints against an EXISTING intent (the mandate) with
|
|
418
453
|
// no fresh assurance — exactly the draw semantics. The fetchCredential
|
|
@@ -435,7 +470,30 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
435
470
|
amountMinor: session.amountMinor,
|
|
436
471
|
transaction: { ...transaction, transactionCurrencyCode: session.currency },
|
|
437
472
|
}, {
|
|
438
|
-
|
|
473
|
+
// Ordering matters: reserve the local ledger FIRST, then ask
|
|
474
|
+
// auth to reserve the server-authoritative budget immediately
|
|
475
|
+
// before the payable mint. A local reserve race/expiry/file
|
|
476
|
+
// failure therefore makes zero auth/verdict calls and cannot
|
|
477
|
+
// strand a server reservation until its sweep.
|
|
478
|
+
fetchCryptogram: async (i) => {
|
|
479
|
+
const { verdict } = await verdictSeam.fetchVerdict({
|
|
480
|
+
authBaseUrl: capability.authBaseUrl,
|
|
481
|
+
agentKey: capability.agentKey,
|
|
482
|
+
mandateId,
|
|
483
|
+
// One draw per review; the reviewId is auth's idempotency key.
|
|
484
|
+
drawId: input.reviewId,
|
|
485
|
+
draw: {
|
|
486
|
+
tokenId: covering.tokenId,
|
|
487
|
+
amount: session.target.transactionAmount,
|
|
488
|
+
currency: session.currency,
|
|
489
|
+
merchantName: session.target.merchantName,
|
|
490
|
+
merchantUrl: session.target.merchantUrl,
|
|
491
|
+
merchantCountryCode: session.target.merchantCountryCode,
|
|
492
|
+
},
|
|
493
|
+
});
|
|
494
|
+
confirmationAuthority = verdict;
|
|
495
|
+
return fetchMandateCryptogram(confirmBase, verdict, i);
|
|
496
|
+
},
|
|
439
497
|
ledger,
|
|
440
498
|
now,
|
|
441
499
|
});
|
|
@@ -463,9 +521,19 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
463
521
|
// Classify on the underlying gateway CAUSE, not the MandateDrawDeclinedError
|
|
464
522
|
// wrapper — the wrapper's own advisory text mentions "try again"/"network",
|
|
465
523
|
// which would otherwise self-classify every decline as transient.
|
|
466
|
-
if (err instanceof MandateDrawDeclinedError
|
|
467
|
-
|
|
468
|
-
|
|
524
|
+
if (err instanceof MandateDrawDeclinedError) {
|
|
525
|
+
const verdictFailure = classifyCardDrawVerdictFailure(err);
|
|
526
|
+
if (verdictFailure) {
|
|
527
|
+
if (!verdictFailure.transient) {
|
|
528
|
+
await ledger.markUnhonored(mandateId, now()).catch(() => { });
|
|
529
|
+
}
|
|
530
|
+
throw new Error(verdictFailure.transient
|
|
531
|
+
? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
532
|
+
: `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase passkey tap`, { cause: err });
|
|
533
|
+
}
|
|
534
|
+
if (!isTransientDrawFailure(err.cause ?? err)) {
|
|
535
|
+
await ledger.markUnhonored(mandateId, now());
|
|
536
|
+
}
|
|
469
537
|
}
|
|
470
538
|
// #5928 CRITICAL-2: the server-side reservation is released by
|
|
471
539
|
// verify-web (it saw the mint fail), not here — the CLI never settles.
|
|
@@ -507,11 +575,11 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
507
575
|
};
|
|
508
576
|
}
|
|
509
577
|
confirmBase = input.approvalBaseUrl;
|
|
510
|
-
|
|
578
|
+
confirmationAuthority = mintToken;
|
|
511
579
|
instrument = new VgsAssuranceInstrument(credential, assurance, session.target, session.contact.fullName ?? '', async (mintInput) => {
|
|
512
|
-
const { intentId, status } = await serverCreateIntent(confirmBase,
|
|
580
|
+
const { intentId, status } = await serverCreateIntent(confirmBase, mintToken, mintInput);
|
|
513
581
|
try {
|
|
514
|
-
const payment = await serverFetchCryptogram(confirmBase,
|
|
582
|
+
const payment = await serverFetchCryptogram(confirmBase, mintToken, {
|
|
515
583
|
tokenId: mintInput.tokenId,
|
|
516
584
|
intentId,
|
|
517
585
|
transaction: mintInput.transaction,
|
|
@@ -542,7 +610,12 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
542
610
|
transactionAmount: session.target.transactionAmount,
|
|
543
611
|
transactionCurrencyCode: session.currency,
|
|
544
612
|
},
|
|
545
|
-
post: (confInput) =>
|
|
613
|
+
post: (confInput) => {
|
|
614
|
+
if (!confirmationAuthority) {
|
|
615
|
+
throw new Error('confirmation authority missing for the consumed VIC intent');
|
|
616
|
+
}
|
|
617
|
+
return postServerConfirmation(confirmBase, confirmationAuthority, confInput);
|
|
618
|
+
},
|
|
546
619
|
});
|
|
547
620
|
}
|
|
548
621
|
let receiptPath = null;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'credential-minted' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
1
|
+
export type EvidenceStepType = 'navigation' | 'dom-stable' | 'review' | 'approval' | 'adapter-selected' | 'reveal' | 'detect' | 'field-fill' | 'amount-fill' | 'credential-minted' | 'credential-skipped' | 'fill-complete' | 'psp-detected' | 'mandate-verdict' | 'submit' | 'challenge-hold' | 'outcome' | 'note';
|
|
2
2
|
export type EvidenceStep = {
|
|
3
3
|
ts: string;
|
|
4
4
|
type: EvidenceStepType;
|
|
@@ -6,7 +6,9 @@ import type { Contact, OtpResolver } from './types.js';
|
|
|
6
6
|
import { EvidenceLog } from './evidence.js';
|
|
7
7
|
import { type ObservedOutcome } from './outcome.js';
|
|
8
8
|
export type CheckoutMode = 'dry-run' | 'submit';
|
|
9
|
-
export type CheckoutOutcome = '
|
|
9
|
+
export type CheckoutOutcome = 'reviewed-dry-run'
|
|
10
|
+
/** Historical receipt value from the credential-disclosing dry-run. */
|
|
11
|
+
| 'filled-dry-run' | 'partial-fill' | 'adapter-required' | 'confirmed' | 'declined' | 'action-required' | 'cancelled' | 'blocked-by-mandate' | 'failed';
|
|
10
12
|
export type CredentialLifecycle = 'not-requested' | 'minted-not-exposed' | 'partially-exposed' | 'fully-filled';
|
|
11
13
|
export type CredentialTiming = {
|
|
12
14
|
approvedAt?: string;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// prepareCheckout(): navigate -> stabilize -> mandate gate -> resolve the
|
|
3
3
|
// review facts. No credential is requested or filled in this phase.
|
|
4
4
|
// submitApprovedCheckout(): verify the approval is bound to that review ->
|
|
5
|
-
// revalidate ->
|
|
5
|
+
// revalidate -> in dry-run stop without requesting a credential; in submit
|
|
6
|
+
// mode mint -> fill -> revalidate -> submit.
|
|
6
7
|
//
|
|
7
8
|
// runCheckout() remains the one-shot, auto-approved compatibility wrapper.
|
|
8
9
|
//
|
|
@@ -11,8 +12,8 @@
|
|
|
11
12
|
// runs before instrument.getCredential(). No credential is minted and no
|
|
12
13
|
// field is filled on a page the mandate does not cover.
|
|
13
14
|
// - The PRE-SUBMIT gate re-runs the full check with the resolved amount and
|
|
14
|
-
// currency. No submit ever happens without it passing. Dry-run
|
|
15
|
-
// clicks submit.
|
|
15
|
+
// currency. No submit ever happens without it passing. Dry-run requests no
|
|
16
|
+
// credential and neither fills fields nor clicks submit.
|
|
16
17
|
import { randomUUID } from 'node:crypto';
|
|
17
18
|
import { mkdir } from 'node:fs/promises';
|
|
18
19
|
import { join } from 'node:path';
|
|
@@ -967,6 +968,16 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
967
968
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, reason);
|
|
968
969
|
}
|
|
969
970
|
evidence.step('approval', { approved: true, reviewId: checkout.review.id });
|
|
971
|
+
if (opts.mode === 'dry-run') {
|
|
972
|
+
evidence.step('credential-skipped', {
|
|
973
|
+
reason: 'dry-run stops before credential mint or merchant-page disclosure',
|
|
974
|
+
});
|
|
975
|
+
evidence.step('submit', { would: true, target: approvalSubmit?.desc ?? 'none found' });
|
|
976
|
+
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
977
|
+
return makeResult('reviewed-dry-run', state.fields, evidence, requiresAdapter, approvalSubmit
|
|
978
|
+
? `validated the reviewed checkout; would click ${approvalSubmit.desc}`
|
|
979
|
+
: 'validated the reviewed checkout; no submit control detected');
|
|
980
|
+
}
|
|
970
981
|
const credential = await opts.instrument.getCredential({
|
|
971
982
|
merchantHost,
|
|
972
983
|
amountMinor: approvedFacts.amountMinor,
|
|
@@ -1099,17 +1110,6 @@ export async function submitApprovedCheckout(reviewId, opts, store = defaultPrep
|
|
|
1099
1110
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1100
1111
|
return makeResult('blocked-by-mandate', state.fields, evidence, requiresAdapter, submitChangedBeforeClick);
|
|
1101
1112
|
}
|
|
1102
|
-
if (opts.mode === 'dry-run') {
|
|
1103
|
-
evidence.step('submit', { would: true, target: submit?.desc ?? 'none found' });
|
|
1104
|
-
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1105
|
-
if (missingCredentialRoles.length > 0) {
|
|
1106
|
-
const adapterRequired = requiresAdapter.size > 0;
|
|
1107
|
-
return makeResult(adapterRequired ? 'adapter-required' : 'partial-fill', state.fields, evidence, requiresAdapter, adapterRequired
|
|
1108
|
-
? `credential fields require adapter: ${[...requiresAdapter].join(', ')}; missing ${missingCredentialRoles.join(', ')}`
|
|
1109
|
-
: `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
1110
|
-
}
|
|
1111
|
-
return makeResult('filled-dry-run', state.fields, evidence, requiresAdapter, submit ? `would click ${submit.desc}` : 'no submit control detected');
|
|
1112
|
-
}
|
|
1113
1113
|
if (missingCredentialRoles.length > 0) {
|
|
1114
1114
|
evidence.setSnapshotSummary(await snapshotSummary(page));
|
|
1115
1115
|
return makeResult('failed', state.fields, evidence, requiresAdapter, `credential fill incomplete: missing ${missingCredentialRoles.join(', ')}`);
|
|
@@ -39,6 +39,12 @@ export type HostedApprovalOptions = {
|
|
|
39
39
|
* accepts many sub-ceiling draws tap-free. Omit for a single-purchase approval.
|
|
40
40
|
*/
|
|
41
41
|
budget?: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Exact current request-key thumbprint receiving a budget. Required for a
|
|
44
|
+
* budget and forbidden for a one-purchase approval; the authenticated page
|
|
45
|
+
* resolves it to the owner's stable agent before displaying or signing.
|
|
46
|
+
*/
|
|
47
|
+
agentJkt?: string;
|
|
42
48
|
/** Advisory max draws the ceiling intent may fulfil — carried onto the token. */
|
|
43
49
|
maxDraws?: number;
|
|
44
50
|
/**
|
|
@@ -132,4 +138,5 @@ export declare function assertApprovalBaseUrl(value: string): string;
|
|
|
132
138
|
*/
|
|
133
139
|
export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<PurchaseAssurance & {
|
|
134
140
|
mintToken?: string;
|
|
141
|
+
validUntil?: number;
|
|
135
142
|
}>;
|
|
@@ -128,7 +128,7 @@ function stripTrailingSlashes(value) {
|
|
|
128
128
|
* rather than falling back to a client-held secret.
|
|
129
129
|
*/
|
|
130
130
|
export async function runHostedApproval(opts) {
|
|
131
|
-
const { baseUrl, tokenId, target, consumerEmail, budget, maxDraws, perTransaction, intent,
|
|
131
|
+
const { baseUrl, tokenId, target, consumerEmail, budget, agentJkt, maxDraws, perTransaction, intent,
|
|
132
132
|
// Default to stderr, NOT a no-op: the approval URL is the one thing a remote /
|
|
133
133
|
// SSH / headless-terminal human needs to proceed, and swallowing it (the old
|
|
134
134
|
// `() => {}` default) left it emitted nowhere readable. Tests inject their own.
|
|
@@ -137,6 +137,13 @@ export async function runHostedApproval(opts) {
|
|
|
137
137
|
// passkey) needs more than the interactive 4-minute window; allow an env
|
|
138
138
|
// override without threading a flag through every caller.
|
|
139
139
|
timeoutMs = resolveApprovalTimeoutMs(), } = opts;
|
|
140
|
+
const canonicalJkt = typeof agentJkt === 'string' && /^[A-Za-z0-9_-]{43}$/.test(agentJkt);
|
|
141
|
+
if (budget && !canonicalJkt) {
|
|
142
|
+
throw new Error('a budget approval requires the exact current agent request-key JKT');
|
|
143
|
+
}
|
|
144
|
+
if (!budget && agentJkt !== undefined) {
|
|
145
|
+
throw new Error('agentJkt is accepted only for a budget approval');
|
|
146
|
+
}
|
|
140
147
|
const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
|
|
141
148
|
// The server's currency map is uppercase ISO 4217; the loader normalizes the
|
|
142
149
|
// target once, but normalize here too so a direct caller with a lowercase
|
|
@@ -183,6 +190,7 @@ export async function runHostedApproval(opts) {
|
|
|
183
190
|
currency,
|
|
184
191
|
...(consumerEmail ? { consumerEmail } : {}),
|
|
185
192
|
...(budget ? { budget: true } : {}),
|
|
193
|
+
...(budget ? { agentJkt } : {}),
|
|
186
194
|
...(budget && maxDraws !== undefined ? { maxDraws } : {}),
|
|
187
195
|
...(budget && perTransaction !== undefined ? { perTransaction } : {}),
|
|
188
196
|
...(intentSanitized !== undefined ? { intent: intentSanitized } : {}),
|
|
@@ -201,6 +209,9 @@ export async function runHostedApproval(opts) {
|
|
|
201
209
|
// effort browser open for the interactive case.
|
|
202
210
|
onApprovalUrl?.(approveUrl);
|
|
203
211
|
log(`approve the purchase in your browser: ${approveUrl}`);
|
|
212
|
+
if (budget && agentJkt) {
|
|
213
|
+
log(`budget recipient request key: ${agentJkt.slice(0, 8)}…${agentJkt.slice(-6)}`);
|
|
214
|
+
}
|
|
204
215
|
openUrl(approveUrl);
|
|
205
216
|
let lastHeartbeat = now();
|
|
206
217
|
// Keep the event loop alive for the whole poll wait. `defaultSleep` unref()'s
|
|
@@ -255,6 +266,13 @@ export async function runHostedApproval(opts) {
|
|
|
255
266
|
throw new Error('hosted approval context mismatch on budget — the approval was not for this ' +
|
|
256
267
|
'exact purchase; run the checkout again for a fresh link');
|
|
257
268
|
}
|
|
269
|
+
if (budget && doc.context?.agentJkt !== agentJkt) {
|
|
270
|
+
throw new Error('hosted approval context mismatch on agentJkt — the approval was not for this ' +
|
|
271
|
+
'exact request key; run the checkout again for a fresh link');
|
|
272
|
+
}
|
|
273
|
+
if (!budget && doc.context?.agentJkt !== undefined) {
|
|
274
|
+
throw new Error('hosted approval unexpectedly carried an agentJkt for a one-purchase request');
|
|
275
|
+
}
|
|
258
276
|
// The advisory draw ceiling must survive the relay intact too — a relay
|
|
259
277
|
// that dropped or altered maxDraws would mint a budget token whose draw
|
|
260
278
|
// count no longer matches what the operator approved. Only the budget
|
|
@@ -277,12 +295,22 @@ export async function runHostedApproval(opts) {
|
|
|
277
295
|
throw new Error('hosted approval context mismatch on intent — the approval was not for this ' +
|
|
278
296
|
'exact purchase; run the checkout again for a fresh link');
|
|
279
297
|
}
|
|
298
|
+
if (budget) {
|
|
299
|
+
if (!Number.isSafeInteger(doc.validUntil) ||
|
|
300
|
+
doc.validUntil <= Math.floor(now() / 1000)) {
|
|
301
|
+
throw new Error('hosted budget approval completed without a valid future expiry');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
else if (doc.validUntil !== undefined) {
|
|
305
|
+
throw new Error('hosted one-purchase approval unexpectedly carried a budget expiry');
|
|
306
|
+
}
|
|
280
307
|
log('passkey approval received from the hosted page.');
|
|
281
308
|
return {
|
|
282
309
|
...assuranceFromCeremony(target, doc.assuranceData),
|
|
283
310
|
...(typeof doc.mintToken === 'string' && doc.mintToken
|
|
284
311
|
? { mintToken: doc.mintToken }
|
|
285
312
|
: {}),
|
|
313
|
+
...(budget ? { validUntil: doc.validUntil } : {}),
|
|
286
314
|
};
|
|
287
315
|
}
|
|
288
316
|
// status 'pending' — the operator is still signing in / tapping.
|
|
@@ -48,7 +48,7 @@ export function assertSubmitAllowed(mode, env = process.env, isInteractive = pro
|
|
|
48
48
|
}
|
|
49
49
|
/** Distinct phrases per mode so dry-run muscle memory can never authorize a payment. */
|
|
50
50
|
export function approvalPhrase(mode, reviewId) {
|
|
51
|
-
return `${mode === 'submit' ? 'PAY' : '
|
|
51
|
+
return `${mode === 'submit' ? 'PAY' : 'CHECK'} ${reviewId}`;
|
|
52
52
|
}
|
|
53
53
|
/** Integer-only minor→display formatting (two-decimal currencies, the same assumption as decimalToMinor). */
|
|
54
54
|
export function formatAmountMinor(amountMinor, currency) {
|
|
@@ -62,11 +62,11 @@ export function approvalQuestion(mode, review, phrase) {
|
|
|
62
62
|
`Type ${JSON.stringify(phrase)} to disclose the credential AND submit the order: `);
|
|
63
63
|
}
|
|
64
64
|
return (`Inspect the visible checkout. Type ${JSON.stringify(phrase)} ` +
|
|
65
|
-
`to
|
|
65
|
+
`to validate the reviewed page without minting, filling, or submitting a credential: `);
|
|
66
66
|
}
|
|
67
67
|
/** The one outcome each mode may exit 0 with. */
|
|
68
68
|
export function isRunSuccess(mode, outcome) {
|
|
69
|
-
return outcome === (mode === 'submit' ? 'confirmed' : '
|
|
69
|
+
return outcome === (mode === 'submit' ? 'confirmed' : 'reviewed-dry-run');
|
|
70
70
|
}
|
|
71
71
|
/**
|
|
72
72
|
* True when the pay control was actually clicked but the run ended with an
|