@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.
@@ -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' : 'FILL'} ${reviewId}`;
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 disclose the credential to this page without submitting: `);
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' : 'filled-dry-run');
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
- /** BUDGET mandate spendable at any merchant (see CardMandateRecord.crossMerchant). */
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 any merchant. The network
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 mint token released by the ceiling approval, reused to draw
34
- * cryptograms tap-free. Owner-only (0600) like the rest of the file; whether
35
- * it stays valid across multiple draws is part of the unproven spike.
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`. Only set when a delegated binding was present and register was
57
- * attempted; a bare bearer mandate (no binding) never carries it. ISO 8601.
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 BUDGET mandate: spendable at ANY merchant, not scoped to `merchantHost`.
62
- * The card network honors a ceiling-scoped cryptogram cross-merchant (the
63
- * mandate's merchant is only an advisory hint on the intent), so this is a
64
- * deliberate product mode the owner approved a spend budget usable anywhere,
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
- // any-merchant budget for merchants that have no scoped mandate. Draining
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 (budget) mandate covers ANY merchant; a merchant-scoped
183
- // one only its own host. A budget mandate the owner approved for "any
184
- // merchant" is deliberately not host-restricted.
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 sealed
27
- * scoped key is NEVER read here — OTP reads go through the out-of-process
28
- * resolver command (below), keeping the keychain gate outside this package.
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
- * keychain-gated `wallet_mail_await_otp` tool: the scoped-key read and the
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 SCOPED KEY is sealed separately and never read here),
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 stands; ` +
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
  }