@visa/cli 4.1.0-rc.261 → 4.1.0-rc.262
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/dist/checkout-engine/cli-engine.d.ts +39 -1
- package/dist/checkout-engine/cli-engine.js +209 -30
- package/dist/checkout-engine/index.d.ts +1 -1
- package/dist/checkout-engine/index.js +1 -1
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +37 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +139 -25
- package/dist/cli.js +414 -414
- package/dist/mcp-server/index.js +321 -321
- package/dist/merchant-ucp-mcp/index.js +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -2,7 +2,7 @@ import { type Browser } from 'playwright-core';
|
|
|
2
2
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutMode, type CheckoutOutcome, type CheckoutFailureCode, type CheckoutResult, type PreparedCheckoutSessionStore } from './executor.js';
|
|
3
3
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
|
|
4
4
|
import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
|
|
5
|
-
import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
5
|
+
import { ServerIntentError, serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
|
|
6
6
|
import { type CardMandateFacts } from './mandate/card-mandate.js';
|
|
7
7
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
8
8
|
import { writeReceipt as realWriteReceipt } from './receipt.js';
|
|
@@ -194,6 +194,43 @@ export type CliMandateFacts = CardMandateFacts & {
|
|
|
194
194
|
*/
|
|
195
195
|
registerFailureReason?: string;
|
|
196
196
|
};
|
|
197
|
+
/**
|
|
198
|
+
* The owner approved a budget but the intent bootstrap did not complete
|
|
199
|
+
* (#8470). `resumable` means the same approval can be resumed without a second
|
|
200
|
+
* passkey ceremony: the server's bootstrap state is at-most-once per signed
|
|
201
|
+
* token, so a resume can only recover an intent that already exists or
|
|
202
|
+
* dispatch once when nothing was ever dispatched — never create a sibling.
|
|
203
|
+
*/
|
|
204
|
+
export type CardMandateActivationFacts = {
|
|
205
|
+
phase: 'intent';
|
|
206
|
+
/** `uncertain`: the provider may have created the intent. `not_created`: proven not. */
|
|
207
|
+
outcome: 'uncertain' | 'not_created';
|
|
208
|
+
resumable: boolean;
|
|
209
|
+
status: number | null;
|
|
210
|
+
errorCode: string | null;
|
|
211
|
+
requestId: string | null;
|
|
212
|
+
bootstrapState: string;
|
|
213
|
+
/** Opaque, process-bound handle for {@link CliCheckoutEngine.resumeCardMandate}. */
|
|
214
|
+
resumeToken?: string;
|
|
215
|
+
/** When the approval's bootstrap credential stops being usable. */
|
|
216
|
+
resumeExpiresAt?: string;
|
|
217
|
+
};
|
|
218
|
+
export declare class CardMandateActivationError extends Error {
|
|
219
|
+
readonly facts: CardMandateActivationFacts;
|
|
220
|
+
readonly code = "CARD_MANDATE_ACTIVATION_INCOMPLETE";
|
|
221
|
+
constructor(message: string, facts: CardMandateActivationFacts);
|
|
222
|
+
}
|
|
223
|
+
export type CliResumeMandateInput = {
|
|
224
|
+
resumeToken: string;
|
|
225
|
+
};
|
|
226
|
+
/**
|
|
227
|
+
* Sort a budget-intent route failure into resume semantics. Exported for the
|
|
228
|
+
* regression net; the truth table is the product contract of #8470.
|
|
229
|
+
*/
|
|
230
|
+
export declare function classifyServerIntentFailure(err: ServerIntentError): {
|
|
231
|
+
outcome: 'uncertain' | 'not_created';
|
|
232
|
+
resumable: boolean;
|
|
233
|
+
};
|
|
197
234
|
type Session = {
|
|
198
235
|
browser: Browser;
|
|
199
236
|
/** Exact caller URL repeated at pay time; may contain a UCP capability. */
|
|
@@ -353,6 +390,7 @@ export type ReceiptWriteObservation = {
|
|
|
353
390
|
};
|
|
354
391
|
export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
|
|
355
392
|
startCardMandate(input: CliStartMandateInput): Promise<CliMandateFacts>;
|
|
393
|
+
resumeCardMandate(input: CliResumeMandateInput): Promise<CliMandateFacts>;
|
|
356
394
|
claimCardMandate(input: CliClaimMandateInput): Promise<CliMandateFacts>;
|
|
357
395
|
review(input: CliReviewInput): Promise<CliReviewFacts>;
|
|
358
396
|
/**
|
|
@@ -11,13 +11,13 @@
|
|
|
11
11
|
// Every browser/network primitive is injectable (CliEngineDeps) so the session/
|
|
12
12
|
// timer/store lifecycle is unit-testable without launching Chromium.
|
|
13
13
|
import { readFile } from 'node:fs/promises';
|
|
14
|
-
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
15
15
|
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
16
16
|
import { RECEIPT_DIR } from './receipt-dir.js';
|
|
17
17
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
18
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
|
|
19
19
|
import { VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
|
|
20
|
-
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
20
|
+
import { serverCreateIntent, serverReadIntentBootstrap, ServerIntentError, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
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';
|
|
@@ -207,6 +207,62 @@ function failedPay(detail) {
|
|
|
207
207
|
credentialDisclosed: false,
|
|
208
208
|
};
|
|
209
209
|
}
|
|
210
|
+
export class CardMandateActivationError extends Error {
|
|
211
|
+
facts;
|
|
212
|
+
code = 'CARD_MANDATE_ACTIVATION_INCOMPLETE';
|
|
213
|
+
constructor(message, facts) {
|
|
214
|
+
super(message);
|
|
215
|
+
this.facts = facts;
|
|
216
|
+
this.name = 'CardMandateActivationError';
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Sort a budget-intent route failure into resume semantics. Exported for the
|
|
221
|
+
* regression net; the truth table is the product contract of #8470.
|
|
222
|
+
*/
|
|
223
|
+
export function classifyServerIntentFailure(err) {
|
|
224
|
+
const { status, errorCode, retryable, bootstrapState, outcome } = err.facts;
|
|
225
|
+
if (bootstrapState === 'created')
|
|
226
|
+
return { outcome: 'not_created', resumable: true };
|
|
227
|
+
if (bootstrapState === 'ambiguous' || errorCode === 'budget_intent_creation_ambiguous') {
|
|
228
|
+
return { outcome: 'uncertain', resumable: true };
|
|
229
|
+
}
|
|
230
|
+
if (bootstrapState === 'pending' || errorCode === 'budget_intent_creation_pending') {
|
|
231
|
+
return { outcome: 'uncertain', resumable: true };
|
|
232
|
+
}
|
|
233
|
+
// An explicit uncertain or unknown outcome wins over retry advice: a
|
|
234
|
+
// retryable failure of the STATUS read says nothing about whether the
|
|
235
|
+
// original dispatch created the intent, so it must not read as not_created.
|
|
236
|
+
// A terminal refusal of the read itself (the approval credential is no
|
|
237
|
+
// longer accepted, or is not a budget token) is still uncertain about the
|
|
238
|
+
// intent but cannot be resumed under that credential.
|
|
239
|
+
if (outcome === 'uncertain' || outcome === 'unknown') {
|
|
240
|
+
const terminalRead = status === 401 || status === 403 || status === 404;
|
|
241
|
+
return { outcome: 'uncertain', resumable: !terminalRead };
|
|
242
|
+
}
|
|
243
|
+
if (retryable)
|
|
244
|
+
return { outcome: 'not_created', resumable: true };
|
|
245
|
+
if (status === 0 || status >= 500) {
|
|
246
|
+
return { outcome: 'uncertain', resumable: true };
|
|
247
|
+
}
|
|
248
|
+
// A completed 4xx refusal (claims, binding, conflict, already registered)
|
|
249
|
+
// proves no intent exists and no resume can change the answer.
|
|
250
|
+
return { outcome: 'not_created', resumable: false };
|
|
251
|
+
}
|
|
252
|
+
function mintTokenExpiryMs(mintToken, fallbackMs) {
|
|
253
|
+
try {
|
|
254
|
+
const payload = mintToken.split('.')[1];
|
|
255
|
+
if (!payload)
|
|
256
|
+
return fallbackMs;
|
|
257
|
+
const claims = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
258
|
+
return typeof claims.exp === 'number' && Number.isFinite(claims.exp)
|
|
259
|
+
? claims.exp * 1000
|
|
260
|
+
: fallbackMs;
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
return fallbackMs;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
210
266
|
function boundedReceiptWriteErrorCode(reason) {
|
|
211
267
|
return reason.match(/\b(?:EACCES|EEXIST|ENOSPC|ENOTDIR|EPERM|EROFS)\b/)?.[0] ?? 'UNKNOWN';
|
|
212
268
|
}
|
|
@@ -222,6 +278,45 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
222
278
|
const sessions = deps.sessions ?? defaultSessions;
|
|
223
279
|
const payAttempts = deps.payAttempts ?? defaultPayAttempts;
|
|
224
280
|
const ttlMs = deps.ttlMs ?? PREPARED_TTL_MS;
|
|
281
|
+
// #8470: process-bound resume handles for a budget whose intent bootstrap
|
|
282
|
+
// did not complete after owner approval. Holds the one-use bootstrap
|
|
283
|
+
// credential in memory only, for at most its own lifetime.
|
|
284
|
+
const pendingActivations = new Map();
|
|
285
|
+
function dropActivation(token) {
|
|
286
|
+
const pending = pendingActivations.get(token);
|
|
287
|
+
if (!pending)
|
|
288
|
+
return;
|
|
289
|
+
clearTimeout(pending.cleanupTimer);
|
|
290
|
+
pendingActivations.delete(token);
|
|
291
|
+
}
|
|
292
|
+
function parkActivation(pending) {
|
|
293
|
+
const token = `act_${randomBytes(18).toString('base64url')}`;
|
|
294
|
+
const cleanupTimer = setTimeout(() => dropActivation(token), Math.max(0, pending.expiresAtMs - now().getTime()));
|
|
295
|
+
cleanupTimer.unref?.();
|
|
296
|
+
pendingActivations.set(token, { ...pending, cleanupTimer });
|
|
297
|
+
return token;
|
|
298
|
+
}
|
|
299
|
+
function activationFailure(err, pending, existingToken) {
|
|
300
|
+
if (!(err instanceof ServerIntentError))
|
|
301
|
+
throw err;
|
|
302
|
+
const classified = classifyServerIntentFailure(err);
|
|
303
|
+
const resumeToken = classified.resumable
|
|
304
|
+
? (existingToken ?? parkActivation(pending))
|
|
305
|
+
: undefined;
|
|
306
|
+
if (!classified.resumable && existingToken)
|
|
307
|
+
dropActivation(existingToken);
|
|
308
|
+
throw new CardMandateActivationError(err.message, {
|
|
309
|
+
phase: 'intent',
|
|
310
|
+
outcome: classified.outcome,
|
|
311
|
+
resumable: classified.resumable,
|
|
312
|
+
status: err.facts.status,
|
|
313
|
+
errorCode: err.facts.errorCode,
|
|
314
|
+
requestId: err.facts.requestId,
|
|
315
|
+
bootstrapState: err.facts.bootstrapState,
|
|
316
|
+
...(resumeToken ? { resumeToken } : {}),
|
|
317
|
+
...(resumeToken ? { resumeExpiresAt: new Date(pending.expiresAtMs).toISOString() } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
225
320
|
const launchBrowser = deps.launchBrowser ?? (() => launchCheckoutBrowser());
|
|
226
321
|
const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
|
|
227
322
|
const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
|
|
@@ -394,6 +489,45 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
394
489
|
...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
|
|
395
490
|
};
|
|
396
491
|
}
|
|
492
|
+
// Shared by mandate-start and resume: mint (or adopt) the ceiling intent,
|
|
493
|
+
// persist the owner-only ledger entry, and register server-side.
|
|
494
|
+
async function activateBudget(pending, createIntent) {
|
|
495
|
+
const facts = await createCardMandate({
|
|
496
|
+
agentJkt: pending.registerCap.agentJkt,
|
|
497
|
+
tokenId: pending.tokenId,
|
|
498
|
+
assuranceData: pending.assuranceData,
|
|
499
|
+
ceilingMinor: pending.ceilingMinor,
|
|
500
|
+
merchant: pending.merchant,
|
|
501
|
+
currencyCode: pending.currency,
|
|
502
|
+
expiresAt: pending.expiresAt,
|
|
503
|
+
maxDraws: DEFAULT_MANDATE_MAX_DRAWS,
|
|
504
|
+
crossMerchant: true,
|
|
505
|
+
}, {
|
|
506
|
+
createIntent,
|
|
507
|
+
ledger,
|
|
508
|
+
approvalBaseUrl: pending.approvalBaseUrl,
|
|
509
|
+
now,
|
|
510
|
+
});
|
|
511
|
+
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
512
|
+
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
513
|
+
// falls back as payable authority.
|
|
514
|
+
const registered = await registerMandateOrDisable({
|
|
515
|
+
registerCap: pending.registerCap,
|
|
516
|
+
mandateId: facts.mandateId,
|
|
517
|
+
mintToken: pending.mintToken,
|
|
518
|
+
ceiling: pending.ceiling,
|
|
519
|
+
currency: pending.currency,
|
|
520
|
+
});
|
|
521
|
+
return {
|
|
522
|
+
...facts,
|
|
523
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
524
|
+
merchantHost: new URL(pending.merchant.url).hostname,
|
|
525
|
+
registerFailed: registered.registerFailed,
|
|
526
|
+
...(registered.registerFailureReason !== undefined
|
|
527
|
+
? { registerFailureReason: registered.registerFailureReason }
|
|
528
|
+
: {}),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
397
531
|
return {
|
|
398
532
|
// BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
|
|
399
533
|
// that ceiling as its decline threshold and the owner-only ledger records
|
|
@@ -469,41 +603,86 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
469
603
|
throw new Error('the approval server issued no valid budget expiry');
|
|
470
604
|
}
|
|
471
605
|
const expiresAt = new Date(assurance.validUntil * 1000).toISOString();
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
tokenId: credential.tokenId,
|
|
606
|
+
const pending = {
|
|
607
|
+
mintToken,
|
|
475
608
|
assuranceData: assurance.assuranceData,
|
|
609
|
+
ceiling: input.ceiling,
|
|
476
610
|
ceilingMinor,
|
|
611
|
+
currency: input.currency,
|
|
477
612
|
merchant,
|
|
478
|
-
currencyCode: input.currency,
|
|
479
613
|
expiresAt,
|
|
480
|
-
|
|
481
|
-
crossMerchant: true,
|
|
482
|
-
}, {
|
|
483
|
-
createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i),
|
|
484
|
-
ledger,
|
|
614
|
+
tokenId: credential.tokenId,
|
|
485
615
|
approvalBaseUrl: input.approvalBaseUrl,
|
|
486
|
-
now,
|
|
487
|
-
});
|
|
488
|
-
// Seed the one server-authoritative cumulative store keyed by the VGS
|
|
489
|
-
// intent ID. A later draw requires its PoP verdict; the budget token never
|
|
490
|
-
// falls back as payable authority.
|
|
491
|
-
const registered = await registerMandateOrDisable({
|
|
492
616
|
registerCap,
|
|
493
|
-
|
|
494
|
-
mintToken,
|
|
495
|
-
ceiling: input.ceiling,
|
|
496
|
-
currency: input.currency,
|
|
497
|
-
});
|
|
498
|
-
return {
|
|
499
|
-
...facts,
|
|
500
|
-
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
501
|
-
merchantHost: new URL(merchant.url).hostname,
|
|
502
|
-
registerFailed: registered.registerFailed,
|
|
503
|
-
...(registered.registerFailureReason !== undefined
|
|
504
|
-
? { registerFailureReason: registered.registerFailureReason }
|
|
505
|
-
: {}),
|
|
617
|
+
expiresAtMs: mintTokenExpiryMs(mintToken, now().getTime() + 10 * 60 * 1000),
|
|
506
618
|
};
|
|
619
|
+
return activateBudget(pending, (i) => serverCreateIntent(input.approvalBaseUrl, mintToken, i)).catch((err) => activationFailure(err, pending));
|
|
620
|
+
},
|
|
621
|
+
// RESUME leg (#8470): the owner already approved, but the intent bootstrap
|
|
622
|
+
// did not complete. Read the server's durable state under the SAME one-use
|
|
623
|
+
// credential: a created intent is registered as-is, nothing-dispatched is
|
|
624
|
+
// dispatched once, pending/ambiguous stays uncertain. No approval page, no
|
|
625
|
+
// passkey, and never a sibling intent.
|
|
626
|
+
async resumeCardMandate(input) {
|
|
627
|
+
const pending = pendingActivations.get(input.resumeToken);
|
|
628
|
+
if (!pending || pending.expiresAtMs <= now().getTime()) {
|
|
629
|
+
if (pending)
|
|
630
|
+
dropActivation(input.resumeToken);
|
|
631
|
+
throw new CardMandateActivationError('no resumable budget activation for this operation — its approval credential expired; start a fresh approval', {
|
|
632
|
+
phase: 'intent',
|
|
633
|
+
outcome: 'not_created',
|
|
634
|
+
resumable: false,
|
|
635
|
+
status: null,
|
|
636
|
+
errorCode: 'activation_resume_expired',
|
|
637
|
+
requestId: null,
|
|
638
|
+
bootstrapState: 'unknown',
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
let read;
|
|
642
|
+
try {
|
|
643
|
+
read = await serverReadIntentBootstrap(pending.approvalBaseUrl, pending.mintToken);
|
|
644
|
+
}
|
|
645
|
+
catch (err) {
|
|
646
|
+
return activationFailure(err, pending, input.resumeToken);
|
|
647
|
+
}
|
|
648
|
+
if (read.state === 'created' && read.intentId) {
|
|
649
|
+
const intentId = read.intentId;
|
|
650
|
+
const facts = await activateBudget(pending, async () => ({
|
|
651
|
+
intentId,
|
|
652
|
+
status: read.intentStatus,
|
|
653
|
+
}));
|
|
654
|
+
dropActivation(input.resumeToken);
|
|
655
|
+
return facts;
|
|
656
|
+
}
|
|
657
|
+
if (read.state === 'none') {
|
|
658
|
+
return activateBudget(pending, (i) => serverCreateIntent(pending.approvalBaseUrl, pending.mintToken, i))
|
|
659
|
+
.then((facts) => {
|
|
660
|
+
dropActivation(input.resumeToken);
|
|
661
|
+
return facts;
|
|
662
|
+
})
|
|
663
|
+
.catch((err) => activationFailure(err, pending, input.resumeToken));
|
|
664
|
+
}
|
|
665
|
+
throw new CardMandateActivationError(read.state === 'ambiguous'
|
|
666
|
+
? 'the card network never confirmed this budget intent and its outcome cannot be verified; this approval cannot be reused'
|
|
667
|
+
: read.state === 'pending'
|
|
668
|
+
? 'this budget intent is still being created; check again shortly'
|
|
669
|
+
: 'the budget activation state could not be read; check again shortly', {
|
|
670
|
+
phase: 'intent',
|
|
671
|
+
outcome: 'uncertain',
|
|
672
|
+
// Stays parked and queryable: a further resume only re-reads state
|
|
673
|
+
// and can never redispatch under this token.
|
|
674
|
+
resumable: true,
|
|
675
|
+
status: null,
|
|
676
|
+
errorCode: read.state === 'ambiguous'
|
|
677
|
+
? 'budget_intent_creation_ambiguous'
|
|
678
|
+
: read.state === 'pending'
|
|
679
|
+
? 'budget_intent_creation_pending'
|
|
680
|
+
: 'budget_intent_state_unavailable',
|
|
681
|
+
requestId: read.requestId,
|
|
682
|
+
bootstrapState: read.state,
|
|
683
|
+
resumeToken: input.resumeToken,
|
|
684
|
+
resumeExpiresAt: new Date(pending.expiresAtMs).toISOString(),
|
|
685
|
+
});
|
|
507
686
|
},
|
|
508
687
|
// PICKUP leg: the owner already approved the ceiling in the account panel
|
|
509
688
|
// and handed this runtime a single-use pickup code. Claim it, verify it was
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
1
|
+
export { createCliCheckoutEngine, type CliReviewInput, type CliReviewFacts, type CliPayInput, type CliReceiptFacts, type CliStartMandateInput, type CliMandateFacts, type CliEngineDeps, type ReceiptWriteObservation, CheckoutReviewRefusedError, CardMandateActivationError, classifyServerIntentFailure, type CardMandateActivationFacts, type CliResumeMandateInput, } from './cli-engine.js';
|
|
2
2
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
3
3
|
export type { CheckoutResult, CheckoutReview, CheckoutOutcome, CheckoutFailureCode, CheckoutRoute, } from './executor.js';
|
|
4
4
|
export { readConfirmedMerchants, type ConfirmedMerchant, type ConfirmedCharge, } from './confirmed-merchants.js';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Public API of @visa/checkout-engine. The pay_merchant tool in @visa/cli
|
|
2
2
|
// consumes createCliCheckoutEngine() through a structural seam; the core engine
|
|
3
3
|
// primitives are re-exported for direct/embedded use.
|
|
4
|
-
export { createCliCheckoutEngine, CheckoutReviewRefusedError, } from './cli-engine.js';
|
|
4
|
+
export { createCliCheckoutEngine, CheckoutReviewRefusedError, CardMandateActivationError, classifyServerIntentFailure, } from './cli-engine.js';
|
|
5
5
|
export { prepareCheckout, submitApprovedCheckout, runCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
6
6
|
export { readConfirmedMerchants, } from './confirmed-merchants.js';
|
|
7
7
|
export { KNOWN_MERCHANT_IDENTITIES } from './known-merchants.js';
|
|
@@ -36,6 +36,31 @@ export declare class ServerCryptogramRefusedError extends Error {
|
|
|
36
36
|
readonly terminal: true;
|
|
37
37
|
constructor(status: number, detail: string);
|
|
38
38
|
}
|
|
39
|
+
export type ServerIntentBootstrapState = 'none' | 'pending' | 'created' | 'ambiguous' | 'conflict' | 'unknown';
|
|
40
|
+
/**
|
|
41
|
+
* The budget-intent route's failure, kept structured (#8470). `status` 0 means
|
|
42
|
+
* the request never produced an HTTP response (network failure or abort), which
|
|
43
|
+
* is outcome-uncertain exactly like a 5xx: the server may have dispatched.
|
|
44
|
+
*/
|
|
45
|
+
export declare class ServerIntentError extends Error {
|
|
46
|
+
readonly facts: {
|
|
47
|
+
status: number;
|
|
48
|
+
errorCode: string | null;
|
|
49
|
+
retryable: boolean;
|
|
50
|
+
requestId: string | null;
|
|
51
|
+
bootstrapState: ServerIntentBootstrapState;
|
|
52
|
+
outcome: 'uncertain' | 'not_created' | 'unknown';
|
|
53
|
+
};
|
|
54
|
+
readonly code = "SERVER_INTENT_FAILED";
|
|
55
|
+
constructor(message: string, facts: {
|
|
56
|
+
status: number;
|
|
57
|
+
errorCode: string | null;
|
|
58
|
+
retryable: boolean;
|
|
59
|
+
requestId: string | null;
|
|
60
|
+
bootstrapState: ServerIntentBootstrapState;
|
|
61
|
+
outcome: 'uncertain' | 'not_created' | 'unknown';
|
|
62
|
+
});
|
|
63
|
+
}
|
|
39
64
|
/**
|
|
40
65
|
* Optional mandate override for serverCreateIntent. Absent → the default
|
|
41
66
|
* intent shape (cap = ceil(amount)+10 min 25, quantity 1, 30-day window).
|
|
@@ -71,6 +96,18 @@ export declare function serverCreateIntent(base: string, mintToken: string, inpu
|
|
|
71
96
|
intentId: string;
|
|
72
97
|
status: string | null;
|
|
73
98
|
}>;
|
|
99
|
+
/**
|
|
100
|
+
* Read the durable budget-bootstrap state for a budget mint token via
|
|
101
|
+
* GET {base}/api/vgs/intent (#8470). A `created` answer carries the intent the
|
|
102
|
+
* server already minted under this token, so the runner registers it instead
|
|
103
|
+
* of dispatching again; `pending` and `ambiguous` are never redispatched.
|
|
104
|
+
*/
|
|
105
|
+
export declare function serverReadIntentBootstrap(base: string, mintToken: string, deps?: ServerMintDeps): Promise<{
|
|
106
|
+
state: ServerIntentBootstrapState;
|
|
107
|
+
intentId: string | null;
|
|
108
|
+
intentStatus: string | null;
|
|
109
|
+
requestId: string | null;
|
|
110
|
+
}>;
|
|
74
111
|
/**
|
|
75
112
|
* Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
|
|
76
113
|
* The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
|
|
@@ -63,9 +63,10 @@ export class ServerCryptogramRefusedError extends Error {
|
|
|
63
63
|
this.status = status;
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
async function readRouteErrorDoc(res) {
|
|
67
|
+
return (await res.json().catch(() => null));
|
|
68
|
+
}
|
|
69
|
+
function routeErrorMessage(res, doc) {
|
|
69
70
|
const base = doc?.error || doc?.error_code || `HTTP ${res.status}`;
|
|
70
71
|
// Carry the provider's own status and rejected-attribute identifiers into the
|
|
71
72
|
// message when the route reflected them. Without this the operator sees only
|
|
@@ -79,6 +80,61 @@ async function routeError(res) {
|
|
|
79
80
|
}
|
|
80
81
|
return parts.length > 0 ? `${base} [${parts.join(' ')}]` : base;
|
|
81
82
|
}
|
|
83
|
+
async function routeError(res) {
|
|
84
|
+
return routeErrorMessage(res, await readRouteErrorDoc(res));
|
|
85
|
+
}
|
|
86
|
+
const BOOTSTRAP_STATES = new Set([
|
|
87
|
+
'none',
|
|
88
|
+
'pending',
|
|
89
|
+
'created',
|
|
90
|
+
'ambiguous',
|
|
91
|
+
'conflict',
|
|
92
|
+
'unknown',
|
|
93
|
+
]);
|
|
94
|
+
function bootstrapStateOf(value) {
|
|
95
|
+
return typeof value === 'string' && BOOTSTRAP_STATES.has(value)
|
|
96
|
+
? value
|
|
97
|
+
: 'unknown';
|
|
98
|
+
}
|
|
99
|
+
const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
100
|
+
function requestIdOf(res, doc) {
|
|
101
|
+
const candidate = res?.headers.get('x-request-id') ?? doc?.request_id ?? null;
|
|
102
|
+
return typeof candidate === 'string' && SAFE_REQUEST_ID.test(candidate) ? candidate : null;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The budget-intent route's failure, kept structured (#8470). `status` 0 means
|
|
106
|
+
* the request never produced an HTTP response (network failure or abort), which
|
|
107
|
+
* is outcome-uncertain exactly like a 5xx: the server may have dispatched.
|
|
108
|
+
*/
|
|
109
|
+
export class ServerIntentError extends Error {
|
|
110
|
+
facts;
|
|
111
|
+
code = 'SERVER_INTENT_FAILED';
|
|
112
|
+
constructor(message, facts) {
|
|
113
|
+
super(message);
|
|
114
|
+
this.facts = facts;
|
|
115
|
+
this.name = 'ServerIntentError';
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function serverIntentErrorFrom(res, doc) {
|
|
119
|
+
const errorCode = typeof doc?.error_code === 'string'
|
|
120
|
+
? doc.error_code
|
|
121
|
+
: typeof doc?.error === 'string' && /^[a-z0-9_]{1,64}$/.test(doc.error)
|
|
122
|
+
? doc.error
|
|
123
|
+
: null;
|
|
124
|
+
const outcome = doc?.outcome === 'uncertain' || doc?.outcome === 'not_created'
|
|
125
|
+
? doc.outcome
|
|
126
|
+
: res.status >= 500 || res.status === 0
|
|
127
|
+
? 'uncertain'
|
|
128
|
+
: 'not_created';
|
|
129
|
+
return new ServerIntentError(`server intent failed (${res.status}): ${routeErrorMessage(res, doc)}`, {
|
|
130
|
+
status: res.status,
|
|
131
|
+
errorCode,
|
|
132
|
+
retryable: doc?.retryable === true,
|
|
133
|
+
requestId: requestIdOf(res, doc),
|
|
134
|
+
bootstrapState: bootstrapStateOf(doc?.bootstrap_state),
|
|
135
|
+
outcome,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
82
138
|
function bearer(mintToken) {
|
|
83
139
|
return { 'content-type': 'application/json', authorization: `Bearer ${mintToken}` };
|
|
84
140
|
}
|
|
@@ -99,34 +155,92 @@ export async function serverCreateIntent(base, mintToken, input, deps = {}) {
|
|
|
99
155
|
const effectiveUntil = override.effectiveUntil ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
100
156
|
const consumerPrompt = override.consumerPrompt ??
|
|
101
157
|
`Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`;
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
158
|
+
let res;
|
|
159
|
+
try {
|
|
160
|
+
res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: bearer(mintToken),
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
tokenId,
|
|
165
|
+
consumerPrompt,
|
|
166
|
+
assuranceData,
|
|
167
|
+
mandates: [
|
|
168
|
+
{
|
|
169
|
+
description: `Purchase at ${t.merchantName}`,
|
|
170
|
+
declineThresholdAmount,
|
|
171
|
+
declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
|
|
172
|
+
effectiveUntil,
|
|
173
|
+
merchantCategory: 'Retail',
|
|
174
|
+
merchantCategoryCode: '5999',
|
|
175
|
+
preferredMerchantName: t.merchantName,
|
|
176
|
+
quantity,
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
}),
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// No HTTP response at all: the request may or may not have reached the
|
|
184
|
+
// server, so this is outcome-uncertain, never a proven non-creation.
|
|
185
|
+
throw new ServerIntentError(`server intent failed (0): ${err instanceof Error ? err.message : String(err)}`, {
|
|
186
|
+
status: 0,
|
|
187
|
+
errorCode: null,
|
|
188
|
+
retryable: false,
|
|
189
|
+
requestId: null,
|
|
190
|
+
bootstrapState: 'unknown',
|
|
191
|
+
outcome: 'uncertain',
|
|
192
|
+
});
|
|
193
|
+
}
|
|
123
194
|
if (!res.ok)
|
|
124
|
-
throw
|
|
195
|
+
throw serverIntentErrorFrom(res, await readRouteErrorDoc(res));
|
|
125
196
|
const doc = (await res.json().catch(() => null));
|
|
126
197
|
if (!doc?.intentId)
|
|
127
198
|
throw new Error('server intent response missing intentId');
|
|
128
199
|
return { intentId: doc.intentId, status: typeof doc.status === 'string' ? doc.status : null };
|
|
129
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Read the durable budget-bootstrap state for a budget mint token via
|
|
203
|
+
* GET {base}/api/vgs/intent (#8470). A `created` answer carries the intent the
|
|
204
|
+
* server already minted under this token, so the runner registers it instead
|
|
205
|
+
* of dispatching again; `pending` and `ambiguous` are never redispatched.
|
|
206
|
+
*/
|
|
207
|
+
export async function serverReadIntentBootstrap(base, mintToken, deps = {}) {
|
|
208
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
209
|
+
let res;
|
|
210
|
+
try {
|
|
211
|
+
res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
|
|
212
|
+
method: 'GET',
|
|
213
|
+
headers: bearer(mintToken),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
throw new ServerIntentError(`server intent status failed (0): ${err instanceof Error ? err.message : String(err)}`, {
|
|
218
|
+
status: 0,
|
|
219
|
+
errorCode: null,
|
|
220
|
+
retryable: true,
|
|
221
|
+
requestId: null,
|
|
222
|
+
bootstrapState: 'unknown',
|
|
223
|
+
outcome: 'unknown',
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
const doc = (await res.json().catch(() => null));
|
|
227
|
+
if (!res.ok) {
|
|
228
|
+
const error = serverIntentErrorFrom(res, doc);
|
|
229
|
+
throw new ServerIntentError(error.message.replace('server intent failed', 'server intent status failed'), {
|
|
230
|
+
...error.facts,
|
|
231
|
+
retryable: error.facts.retryable || res.status === 503,
|
|
232
|
+
outcome: 'unknown',
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
const state = bootstrapStateOf(doc?.bootstrap_state);
|
|
236
|
+
const intentId = typeof doc?.intent?.intentId === 'string' && doc.intent.intentId ? doc.intent.intentId : null;
|
|
237
|
+
return {
|
|
238
|
+
state: state === 'created' && !intentId ? 'unknown' : state,
|
|
239
|
+
intentId,
|
|
240
|
+
intentStatus: typeof doc?.intent?.status === 'string' ? doc.intent.status : null,
|
|
241
|
+
requestId: requestIdOf(res, doc),
|
|
242
|
+
};
|
|
243
|
+
}
|
|
130
244
|
/**
|
|
131
245
|
* Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
|
|
132
246
|
* The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
|