@visa/cli 4.1.0-rc.38 → 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/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 +263 -262
- package/dist/mcp-server/index.js +195 -194
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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
|
|
@@ -18,6 +18,8 @@ export type CardMandateMerchant = {
|
|
|
18
18
|
countryCode: string;
|
|
19
19
|
};
|
|
20
20
|
export type CreateCardMandateInput = {
|
|
21
|
+
/** Exact current runtime request key selected before human approval. */
|
|
22
|
+
agentJkt: string;
|
|
21
23
|
tokenId: string;
|
|
22
24
|
assuranceData: unknown;
|
|
23
25
|
ceilingMinor: number;
|
|
@@ -26,7 +28,7 @@ export type CreateCardMandateInput = {
|
|
|
26
28
|
expiresAt: string;
|
|
27
29
|
/** Max draws the ceiling intent may fulfil; defaults to DEFAULT_MANDATE_MAX_DRAWS. */
|
|
28
30
|
maxDraws?: number;
|
|
29
|
-
/**
|
|
31
|
+
/** Cross-host retail budget; network category eligibility still applies. */
|
|
30
32
|
crossMerchant?: boolean;
|
|
31
33
|
};
|
|
32
34
|
export type CreateIntentFn = (input: {
|
|
@@ -60,6 +62,8 @@ export type CardMandateFacts = {
|
|
|
60
62
|
currencyCode: string;
|
|
61
63
|
merchant: CardMandateMerchant;
|
|
62
64
|
expiresAt: string;
|
|
65
|
+
/** Network-enforced maximum purchase count disclosed at approval. */
|
|
66
|
+
maxDraws: number;
|
|
63
67
|
};
|
|
64
68
|
/**
|
|
65
69
|
* Create a card mandate: approve a spend CEILING once (the passkey assurance
|
|
@@ -61,6 +61,9 @@ export async function createCardMandate(input, deps) {
|
|
|
61
61
|
}
|
|
62
62
|
if (!input.tokenId?.trim())
|
|
63
63
|
throw new Error('mandate requires a tokenId');
|
|
64
|
+
if (!/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/.test(input.agentJkt)) {
|
|
65
|
+
throw new Error('mandate requires a canonical agent request-key JKT');
|
|
66
|
+
}
|
|
64
67
|
const now = (deps.now ?? (() => new Date()))();
|
|
65
68
|
const expiryMs = Date.parse(input.expiresAt);
|
|
66
69
|
if (!Number.isFinite(expiryMs))
|
|
@@ -109,6 +112,7 @@ export async function createCardMandate(input, deps) {
|
|
|
109
112
|
const record = {
|
|
110
113
|
version: CARD_MANDATE_LEDGER_VERSION,
|
|
111
114
|
mandateId: intentId,
|
|
115
|
+
agentJkt: input.agentJkt,
|
|
112
116
|
tokenId: input.tokenId,
|
|
113
117
|
ceilingMinor: input.ceilingMinor,
|
|
114
118
|
spentMinor: 0,
|
|
@@ -121,6 +125,7 @@ export async function createCardMandate(input, deps) {
|
|
|
121
125
|
approvalBaseUrl: deps.approvalBaseUrl,
|
|
122
126
|
...(deps.mintToken ? { mintToken: deps.mintToken } : {}),
|
|
123
127
|
expiresAt: new Date(expiryMs).toISOString(),
|
|
128
|
+
maxDraws,
|
|
124
129
|
createdAt: now.toISOString(),
|
|
125
130
|
draws: [],
|
|
126
131
|
...(input.crossMerchant ? { crossMerchant: true } : {}),
|
|
@@ -133,6 +138,7 @@ export async function createCardMandate(input, deps) {
|
|
|
133
138
|
currencyCode,
|
|
134
139
|
merchant: input.merchant,
|
|
135
140
|
expiresAt: record.expiresAt,
|
|
141
|
+
maxDraws,
|
|
136
142
|
};
|
|
137
143
|
}
|
|
138
144
|
const DRAW_REMEDY = 'the card-mandate draw did not complete — a ceiling-scoped assurance may not be honored ' +
|
|
@@ -180,7 +186,7 @@ export async function drawFromMandate(input, deps) {
|
|
|
180
186
|
`${JSON.stringify(input.transaction.transactionAmount)} (minor units)`);
|
|
181
187
|
}
|
|
182
188
|
// Currency must always match; merchant must match UNLESS this is a budget
|
|
183
|
-
// (crossMerchant) mandate the owner approved for
|
|
189
|
+
// (crossMerchant) mandate the owner approved for eligible retail merchants. The network
|
|
184
190
|
// honors a ceiling-scoped cryptogram cross-merchant, so a budget mandate is
|
|
185
191
|
// deliberately not host-restricted — the ceiling + per-transaction limit still
|
|
186
192
|
// bound it.
|
|
@@ -16,6 +16,8 @@ export type CardMandateRecord = {
|
|
|
16
16
|
version: typeof CARD_MANDATE_LEDGER_VERSION;
|
|
17
17
|
/** == the VGS intentId the ceiling approval created. */
|
|
18
18
|
mandateId: string;
|
|
19
|
+
/** Exact runtime request key this budget was issued to. Absent on legacy rows. */
|
|
20
|
+
agentJkt?: string;
|
|
19
21
|
tokenId: string;
|
|
20
22
|
ceilingMinor: number;
|
|
21
23
|
/** Permanently committed (drawn) spend, integer minor units. */
|
|
@@ -30,12 +32,14 @@ export type CardMandateRecord = {
|
|
|
30
32
|
/** verify-web origin the ceiling intent was minted against. */
|
|
31
33
|
approvalBaseUrl: string;
|
|
32
34
|
/**
|
|
33
|
-
* Scoped
|
|
34
|
-
*
|
|
35
|
-
*
|
|
35
|
+
* Scoped token released by the ceiling approval. It bootstraps the one intent
|
|
36
|
+
* + register ceremony only. Each cryptogram AND its outcome confirmation use
|
|
37
|
+
* the exact per-draw verdict instead. Owner-only (0600).
|
|
36
38
|
*/
|
|
37
39
|
mintToken?: string;
|
|
38
40
|
expiresAt: string;
|
|
41
|
+
/** Network purchase-count cap disclosed at approval. Absent on legacy rows. */
|
|
42
|
+
maxDraws?: number;
|
|
39
43
|
createdAt: string;
|
|
40
44
|
draws: CardMandateDraw[];
|
|
41
45
|
/**
|
|
@@ -53,18 +57,15 @@ export type CardMandateRecord = {
|
|
|
53
57
|
* delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
|
|
54
58
|
* findCovering() SKIPS a register-failed mandate so the next checkout falls
|
|
55
59
|
* through to a fresh per-purchase tap instead of surfacing a confusing
|
|
56
|
-
* `no_mandate`.
|
|
57
|
-
*
|
|
60
|
+
* `no_mandate`. Mandate-start requires delegated card authority, so every
|
|
61
|
+
* created record attempted registration. ISO 8601.
|
|
58
62
|
*/
|
|
59
63
|
registerFailedAt?: string;
|
|
60
64
|
/**
|
|
61
|
-
* A
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* bounded by the same ceiling + per-transaction limit. When set, findCovering
|
|
66
|
-
* matches this mandate for any merchant and drawFromMandate skips the
|
|
67
|
-
* merchant-match guard. Absent/false = the default merchant-scoped mandate.
|
|
65
|
+
* A cross-host retail budget, not scoped to one `merchantHost`. The local
|
|
66
|
+
* selector may attempt it at any host, while the provider/network still
|
|
67
|
+
* applies the approved Retail/5999 category plus amount/count/time controls.
|
|
68
|
+
* `crossMerchant` is retained as the version-1 persisted field name.
|
|
68
69
|
*/
|
|
69
70
|
crossMerchant?: boolean;
|
|
70
71
|
};
|
|
@@ -75,6 +75,13 @@ function isExpired(record, now) {
|
|
|
75
75
|
const end = Date.parse(record.expiresAt);
|
|
76
76
|
return !Number.isFinite(end) || end <= now.getTime();
|
|
77
77
|
}
|
|
78
|
+
function committedDrawCount(record) {
|
|
79
|
+
return record.draws.filter((draw) => draw.status === 'committed').length;
|
|
80
|
+
}
|
|
81
|
+
function hasDrawCountHeadroom(record) {
|
|
82
|
+
return (record.maxDraws === undefined ||
|
|
83
|
+
committedDrawCount(record) + record.reservations.length < record.maxDraws);
|
|
84
|
+
}
|
|
78
85
|
function assertPositiveInteger(value, label) {
|
|
79
86
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
80
87
|
throw new Error(`${label} must be a positive integer (minor units)`);
|
|
@@ -172,21 +179,22 @@ export class MandateLedger {
|
|
|
172
179
|
// Selection order among covering candidates:
|
|
173
180
|
// 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
|
|
174
181
|
// spend the dedicated grant for this merchant first and keep the broader
|
|
175
|
-
//
|
|
182
|
+
// broader retail budget for merchants that have no scoped mandate. Draining
|
|
176
183
|
// the budget for a purchase a scoped mandate already covers both wastes
|
|
177
184
|
// the general headroom and can later force a fresh tap at another merchant.
|
|
178
185
|
// 2. Then the MOST headroom, then the latest expiry — never let a near-empty
|
|
179
186
|
// or near-expiry mandate get selected over a fuller, longer-lived sibling
|
|
180
187
|
// and then fail a draw the sibling would have covered.
|
|
181
188
|
const candidates = file.mandates.filter((m) =>
|
|
182
|
-
// A crossMerchant
|
|
183
|
-
// one only its own host.
|
|
184
|
-
//
|
|
189
|
+
// A crossMerchant retail budget is locally cross-host; a merchant-scoped
|
|
190
|
+
// one only matches its own host. Network Retail/5999 eligibility is
|
|
191
|
+
// enforced downstream and is not inferred from a website hostname.
|
|
185
192
|
(m.crossMerchant || m.merchantHost === query.merchantHost) &&
|
|
186
193
|
m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
|
|
187
194
|
!m.unhonoredAt &&
|
|
188
195
|
!m.registerFailedAt &&
|
|
189
196
|
!isExpired(m, now) &&
|
|
197
|
+
hasDrawCountHeadroom(m) &&
|
|
190
198
|
remainingMinor(m) >= query.amountMinor);
|
|
191
199
|
candidates.sort((a, b) =>
|
|
192
200
|
// A scoped mandate (crossMerchant falsy → 0) sorts before a budget one (1).
|
|
@@ -255,6 +263,9 @@ export class MandateLedger {
|
|
|
255
263
|
if (amountMinor > remainingMinor(record)) {
|
|
256
264
|
throw new Error(`draw ${amountMinor} exceeds remaining budget ${remainingMinor(record)} (minor units)`);
|
|
257
265
|
}
|
|
266
|
+
if (!hasDrawCountHeadroom(record)) {
|
|
267
|
+
throw new Error(`mandate ${mandateId} reached its approved purchase-count limit`);
|
|
268
|
+
}
|
|
258
269
|
const reservationId = `rsv_${randomBytes(9).toString('base64url')}`;
|
|
259
270
|
record.reservations.push({ reservationId, amountMinor, reservedAt: now.toISOString() });
|
|
260
271
|
await this.save(file);
|
|
@@ -23,9 +23,10 @@ function v4Identity(file) {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* The agent's provisioned inbox address from mailbox.json, or null when no
|
|
26
|
-
* inbox exists. Non-sensitive: address + opaque inbox id only. The
|
|
27
|
-
* scoped
|
|
28
|
-
* resolver command (below), keeping the
|
|
26
|
+
* inbox exists. Non-sensitive: address + opaque inbox id only. The exportable
|
|
27
|
+
* scoped bearer credential is NEVER read here — OTP reads go through the
|
|
28
|
+
* out-of-process resolver command (below), keeping the owner-only file gate
|
|
29
|
+
* outside this package.
|
|
29
30
|
*/
|
|
30
31
|
function loadAgentInboxEmail() {
|
|
31
32
|
try {
|
|
@@ -42,7 +43,7 @@ function loadAgentInboxEmail() {
|
|
|
42
43
|
* @visa/checkout-engine depends on playwright-core ONLY, so it must not import
|
|
43
44
|
* @visa/wallet-tools/@visa/agent-mail to read OTPs. Instead the operator wires
|
|
44
45
|
* an OUT-OF-PROCESS command (VISA_V4_OTP_RESOLVER_CMD) that shells to the
|
|
45
|
-
*
|
|
46
|
+
* owner-only-credential-gated `wallet_mail_await_otp` tool: the scoped-key read and the
|
|
46
47
|
* from-domain guard live entirely behind that command. The command receives a
|
|
47
48
|
* JSON request on argv and must print JSON `{code,fromDomain}` (or `null`) to
|
|
48
49
|
* stdout. Absent ⇒ no resolver ⇒ the executor hands email OTP to a human.
|
|
@@ -158,7 +159,7 @@ const contact = await readOwnerOnlyJson(contactFile, 'contact file');
|
|
|
158
159
|
// human's address: the merchant emails OTP/verification codes to whatever
|
|
159
160
|
// contact.email it is given, and wallet_mail_await_otp can only read the
|
|
160
161
|
// agent inbox. mailbox.json is non-sensitive provisioning state (address +
|
|
161
|
-
// opaque inbox id — the
|
|
162
|
+
// opaque inbox id — the scoped bearer credential is stored separately and never read here),
|
|
162
163
|
// so the runner reads it directly. Only override when a mailbox exists;
|
|
163
164
|
// otherwise keep the contact-file email.
|
|
164
165
|
const agentInboxEmail = loadAgentInboxEmail();
|
|
@@ -32,8 +32,8 @@ export async function reportVicOutcome(input) {
|
|
|
32
32
|
catch (err) {
|
|
33
33
|
return {
|
|
34
34
|
posted: false,
|
|
35
|
-
reason: `confirmation POST failed: ${err.message} — the merchant outcome
|
|
36
|
-
'retry the confirmation for this intent out-of-band',
|
|
35
|
+
reason: `confirmation POST failed: ${err.message} — the merchant-reported outcome ` +
|
|
36
|
+
'remains unverified; retry the confirmation for this intent out-of-band',
|
|
37
37
|
};
|
|
38
38
|
}
|
|
39
39
|
}
|