@visa/cli 4.1.0-rc.90 → 4.1.0-rc.91

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.
@@ -1,6 +1,6 @@
1
1
  import { type Browser } from 'playwright-core';
2
2
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type PreparedCheckoutSessionStore } from './executor.js';
3
- import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
3
+ import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
4
4
  import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
5
5
  import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
6
6
  import { type CardMandateFacts } from './mandate/card-mandate.js';
@@ -84,6 +84,16 @@ export type CliStartMandateInput = {
84
84
  */
85
85
  intent?: string;
86
86
  };
87
+ export type CliClaimMandateInput = {
88
+ /** Optional local card-capability selector (legacy name or exact request-key JKT). */
89
+ agentRef?: string;
90
+ /** Single-use pickup code the owner handed over from the panel. */
91
+ pickupCode: string;
92
+ credentialPath: string;
93
+ /** See {@link CardInstrumentSource}. */
94
+ cardTokenId?: string;
95
+ approvalBaseUrl: string;
96
+ };
87
97
  export type CliMandateFacts = CardMandateFacts & {
88
98
  merchantHost: string;
89
99
  /**
@@ -164,6 +174,8 @@ export type CliEngineDeps = {
164
174
  prepareCheckout?: typeof realPrepareCheckout;
165
175
  submitApprovedCheckout?: typeof realSubmitApprovedCheckout;
166
176
  runHostedApproval?: typeof realRunHostedApproval;
177
+ /** Injectable pickup-code redeem for claimCardMandate — tests pass a fake. */
178
+ claimMandatePickup?: typeof realClaimMandatePickup;
167
179
  /**
168
180
  * Relay the hosted-approval URL to the caller as DATA the moment it is known,
169
181
  * before the (up-to-timeout) poll wait. A headless agent surface wires this to
@@ -208,6 +220,7 @@ export type CliEngineDeps = {
208
220
  };
209
221
  export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
210
222
  startCardMandate(input: CliStartMandateInput): Promise<CliMandateFacts>;
223
+ claimCardMandate(input: CliClaimMandateInput): Promise<CliMandateFacts>;
211
224
  review(input: CliReviewInput): Promise<CliReviewFacts>;
212
225
  pay(input: CliPayInput): Promise<CliReceiptFacts>;
213
226
  };
@@ -15,7 +15,7 @@ import { join } from 'node:path';
15
15
  import { readFile } from 'node:fs/promises';
16
16
  import { launchCheckoutBrowser } from './browser-launch.js';
17
17
  import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
18
- import { runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
18
+ import { claimMandatePickup as realClaimMandatePickup, 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
21
  import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
@@ -114,6 +114,7 @@ export function createCliCheckoutEngine(deps = {}) {
114
114
  const prepareCheckout = deps.prepareCheckout ?? realPrepareCheckout;
115
115
  const submitApprovedCheckout = deps.submitApprovedCheckout ?? realSubmitApprovedCheckout;
116
116
  const runHostedApproval = deps.runHostedApproval ?? realRunHostedApproval;
117
+ const claimMandatePickup = deps.claimMandatePickup ?? realClaimMandatePickup;
117
118
  const reportVicOutcome = deps.reportVicOutcome ?? realReportVicOutcome;
118
119
  const writeReceipt = deps.writeReceipt ?? realWriteReceipt;
119
120
  const ledger = deps.ledger ?? defaultLedger;
@@ -139,6 +140,55 @@ export function createCliCheckoutEngine(deps = {}) {
139
140
  transactionCurrencyCode: input.currency,
140
141
  };
141
142
  }
143
+ // Seed the one server-authoritative cumulative store keyed by the VGS intent
144
+ // ID (#5942). On failure the local record is marked register-failed so
145
+ // findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
146
+ // and the caller reports the failure honestly. Shared by mandate-start and
147
+ // pickup-claim; both refuse before their ceremony/claim when no capability
148
+ // can register, so registerCap is always present here.
149
+ async function registerMandateOrDisable(args) {
150
+ if (!cardMandateRegister)
151
+ return { registerFailed: true, registerFailureReason: 'no_seam' };
152
+ const reg = await cardMandateRegister
153
+ .register({
154
+ authBaseUrl: args.registerCap.authBaseUrl,
155
+ agentKey: args.registerCap.agentKey,
156
+ mandateId: args.mandateId,
157
+ mintToken: args.mintToken,
158
+ ceiling: args.ceiling,
159
+ currency: args.currency,
160
+ })
161
+ .catch((err) => ({
162
+ ok: false,
163
+ reason: err instanceof Error ? err.message : String(err),
164
+ }));
165
+ if (reg.ok)
166
+ return { registerFailed: false };
167
+ // Register failed: the server `card_mandate_spend` row was never created, so
168
+ // a delegated draw against this mandate would 404 `no_mandate`. Mark it
169
+ // register-failed so findCovering() SKIPS it and the owner's next checkout
170
+ // falls through to a fresh per-purchase tap, rather than silently selecting
171
+ // a mandate that can't be drawn. Marking is best-effort too — a failure here
172
+ // must not abort the flow.
173
+ const marked = await ledger
174
+ .markRegisterFailed(args.mandateId, now())
175
+ .then(() => true)
176
+ .catch(() => false);
177
+ process.stderr.write(marked
178
+ ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
179
+ `will NOT be used for tap-free draws; your next checkout will use a fresh ` +
180
+ `per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
181
+ : // Escalate: the mark write ALSO failed, so the mandate is persisted but
182
+ // NOT disabled — findCovering could still select an undrawable mandate.
183
+ // Tell the owner loudly not to rely on it and how to recover.
184
+ `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
185
+ `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
186
+ `'mandate start'. mandateId=${args.mandateId}\n`);
187
+ return {
188
+ registerFailed: true,
189
+ ...(reg.reason !== undefined ? { registerFailureReason: reg.reason } : {}),
190
+ };
191
+ }
142
192
  return {
143
193
  // BUDGET step: one passkey approves a CEILING; a VGS intent is minted with
144
194
  // that ceiling as its decline threshold and the owner-only ledger records
@@ -234,52 +284,80 @@ export function createCliCheckoutEngine(deps = {}) {
234
284
  // Seed the one server-authoritative cumulative store keyed by the VGS
235
285
  // intent ID. A later draw requires its PoP verdict; the budget token never
236
286
  // falls back as payable authority.
237
- let registerFailed = false;
238
- let registerFailureReason;
239
- if (registerCap && cardMandateRegister) {
240
- const reg = await cardMandateRegister
241
- .register({
242
- authBaseUrl: registerCap.authBaseUrl,
243
- agentKey: registerCap.agentKey,
244
- mandateId: facts.mandateId,
245
- mintToken,
246
- ceiling: input.ceiling,
247
- currency: input.currency,
248
- })
249
- .catch((err) => ({
250
- ok: false,
251
- reason: err instanceof Error ? err.message : String(err),
252
- }));
253
- if (!reg.ok) {
254
- registerFailed = true;
255
- registerFailureReason = reg.reason;
256
- // Register failed: the server `card_mandate_spend` row was never
257
- // created, so a delegated draw against this mandate would 404
258
- // `no_mandate`. Mark it register-failed so findCovering() SKIPS it and
259
- // the owner's next checkout falls through to a fresh per-purchase tap,
260
- // rather than silently selecting a mandate that can't be drawn. Marking
261
- // is best-effort too — a failure here must not abort mandate-start.
262
- const marked = await ledger
263
- .markRegisterFailed(facts.mandateId, now())
264
- .then(() => true)
265
- .catch(() => false);
266
- process.stderr.write(marked
267
- ? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
268
- `will NOT be used for tap-free draws; your next checkout will use a fresh ` +
269
- `per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
270
- : // Escalate: the mark write ALSO failed, so the mandate is persisted but
271
- // NOT disabled — findCovering could still select an undrawable mandate.
272
- // Tell the owner loudly not to rely on it and how to recover.
273
- `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) AND the mandate ` +
274
- `could NOT be disabled locally — do not rely on it. Run 'mandate list' and re-run ` +
275
- `'mandate start'. mandateId=${facts.mandateId}\n`);
276
- }
277
- }
287
+ const registered = await registerMandateOrDisable({
288
+ registerCap,
289
+ mandateId: facts.mandateId,
290
+ mintToken,
291
+ ceiling: input.ceiling,
292
+ currency: input.currency,
293
+ });
278
294
  return {
279
295
  ...facts,
280
296
  merchantHost: new URL(merchant.url).hostname,
281
- registerFailed,
282
- ...(registerFailureReason !== undefined ? { registerFailureReason } : {}),
297
+ registerFailed: registered.registerFailed,
298
+ ...(registered.registerFailureReason !== undefined
299
+ ? { registerFailureReason: registered.registerFailureReason }
300
+ : {}),
301
+ };
302
+ },
303
+ // PICKUP leg: the owner already approved the ceiling in the account panel
304
+ // and handed this runtime a single-use pickup code. Claim it, verify it was
305
+ // minted for exactly this runtime's request key + card token, then run the
306
+ // same intent → register → ledger sequence as startCardMandate. No approval
307
+ // page, no passkey, no contact profile — the human side already happened.
308
+ async claimCardMandate(input) {
309
+ const registerCap = cardMandateRegister?.loadCapability(input.agentRef) ?? null;
310
+ if (!cardMandateRegister || !registerCap) {
311
+ throw new Error('claimCardMandate requires separately provisioned card authority in this runtime; ' +
312
+ 'identity pairing alone does not grant a card mandate');
313
+ }
314
+ const credential = await resolveCardInstrument(input);
315
+ const claim = await claimMandatePickup({
316
+ baseUrl: input.approvalBaseUrl,
317
+ pickupCode: input.pickupCode,
318
+ agentJkt: registerCap.agentJkt,
319
+ tokenId: credential.tokenId,
320
+ });
321
+ const ceilingMinor = decimalToMinor(claim.ceiling);
322
+ if (ceilingMinor === null || ceilingMinor <= 0) {
323
+ throw new Error(`the handoff carried an invalid ceiling ${JSON.stringify(claim.ceiling)}`);
324
+ }
325
+ // Same single-product bound as mandate-start (the panel initiate route
326
+ // enforces it too; a divergent store entry must not widen it here).
327
+ if (claim.currency.toUpperCase() !== 'USD') {
328
+ throw new Error('card spend budgets currently support USD only');
329
+ }
330
+ const expiresAt = new Date(claim.validUntil * 1000).toISOString();
331
+ const facts = await createCardMandate({
332
+ agentJkt: registerCap.agentJkt,
333
+ tokenId: credential.tokenId,
334
+ assuranceData: claim.assuranceData,
335
+ ceilingMinor,
336
+ merchant: claim.merchant,
337
+ currencyCode: claim.currency,
338
+ expiresAt,
339
+ maxDraws: claim.maxDraws,
340
+ crossMerchant: true,
341
+ }, {
342
+ createIntent: (i) => serverCreateIntent(input.approvalBaseUrl, claim.mintToken, i),
343
+ ledger,
344
+ approvalBaseUrl: input.approvalBaseUrl,
345
+ now,
346
+ });
347
+ const registered = await registerMandateOrDisable({
348
+ registerCap,
349
+ mandateId: facts.mandateId,
350
+ mintToken: claim.mintToken,
351
+ ceiling: claim.ceiling,
352
+ currency: claim.currency,
353
+ });
354
+ return {
355
+ ...facts,
356
+ merchantHost: new URL(claim.merchant.url).hostname,
357
+ registerFailed: registered.registerFailed,
358
+ ...(registered.registerFailureReason !== undefined
359
+ ? { registerFailureReason: registered.registerFailureReason }
360
+ : {}),
283
361
  };
284
362
  },
285
363
  async review(input) {
@@ -140,3 +140,48 @@ export declare function runHostedApproval(opts: HostedApprovalOptions): Promise<
140
140
  mintToken?: string;
141
141
  validUntil?: number;
142
142
  }>;
143
+ /**
144
+ * A panel-initiated mandate handoff, released by its single-use pickup code.
145
+ * The context here is SERVER truth (an owner-authenticated panel session parked
146
+ * it, and the code is the credential that releases it) — the runner had no part
147
+ * in registering it, so unlike {@link runHostedApproval} the fields are
148
+ * returned for the caller to act on rather than compared against a local run.
149
+ * What IS verified here is the binding: the entry must be a budget approval
150
+ * minted for exactly THIS runtime's request key and THIS device's card token.
151
+ */
152
+ export type MandatePickupClaim = {
153
+ /** Null only when {@link assuranceExempt} — a code-verified card runs no ceremony. */
154
+ assuranceData: unknown;
155
+ assuranceExempt: boolean;
156
+ mintToken: string;
157
+ /** Unix seconds — the mandate lapse the owner approved in the panel. */
158
+ validUntil: number;
159
+ /** Decimal ceiling string exactly as approved (the context's `amount`). */
160
+ ceiling: string;
161
+ currency: string;
162
+ maxDraws: number;
163
+ perTransaction?: string;
164
+ merchant: {
165
+ name: string;
166
+ url: string;
167
+ countryCode: string;
168
+ };
169
+ };
170
+ export type ClaimMandatePickupOptions = {
171
+ /** The enrollment web deployment origin (apps/web). */
172
+ baseUrl: string;
173
+ /** Single-use pickup code (the approval verifier) the owner handed over. */
174
+ pickupCode: string;
175
+ /** This runtime's exact current request-key JKT — the only key the code may fund. */
176
+ agentJkt: string;
177
+ /** The card token id this runtime's card authority points at. */
178
+ tokenId: string;
179
+ fetchImpl?: typeof fetch;
180
+ /** Injectable for tests — never wall-clock-sleep in a unit test. */
181
+ sleep?: (ms: number) => Promise<void>;
182
+ now?: () => number;
183
+ /** Bounded retries for transient 429/5xx; the claim itself is single-use. */
184
+ attempts?: number;
185
+ retryDelayMs?: number;
186
+ };
187
+ export declare function claimMandatePickup(opts: ClaimMandatePickupOptions): Promise<MandatePickupClaim>;
@@ -346,3 +346,133 @@ export async function runHostedApproval(opts) {
346
346
  clearInterval(keepAlive);
347
347
  }
348
348
  }
349
+ const PICKUP_CODE_SHAPE = /^[A-Za-z0-9_-]{43}$/;
350
+ const DECIMAL_AMOUNT_SHAPE = /^[0-9]+(\.[0-9]{1,2})?$/;
351
+ function pickupMismatch(field, detail) {
352
+ return new Error(`mandate pickup code mismatch on ${field} — ${detail}. Ask the owner to start a fresh ` +
353
+ 'mandate in the panel for this agent');
354
+ }
355
+ export async function claimMandatePickup(opts) {
356
+ const { baseUrl, pickupCode, agentJkt, tokenId, fetchImpl = fetch, sleep = defaultSleep, now = Date.now, attempts = 3, retryDelayMs = 1_000, } = opts;
357
+ // Both producers (runHostedApproval and the panel initiate route) mint the
358
+ // verifier as randomBytes(32).base64url = 43 chars; refuse anything else
359
+ // before it travels.
360
+ if (!PICKUP_CODE_SHAPE.test(pickupCode)) {
361
+ throw new Error('that does not look like a mandate pickup code (43 URL-safe characters) — ' +
362
+ 'paste the code exactly as the panel displayed it');
363
+ }
364
+ if (!/^[A-Za-z0-9_-]{43}$/.test(agentJkt)) {
365
+ throw new Error('claiming a mandate pickup requires the exact current agent request-key JKT');
366
+ }
367
+ if (!tokenId.trim()) {
368
+ throw new Error('claiming a mandate pickup requires the provisioned card token id');
369
+ }
370
+ const base = stripTrailingSlashes(assertApprovalBaseUrl(baseUrl));
371
+ let res = null;
372
+ for (let attempt = 1;; attempt++) {
373
+ res = await fetchImpl(`${base}/api/vgs/agent-approval/claim`, {
374
+ method: 'POST',
375
+ headers: { 'content-type': 'application/json' },
376
+ body: JSON.stringify({ verifier: pickupCode }),
377
+ });
378
+ // 4xx other than 404/429 is a terminal shape/validation answer; 404 is the
379
+ // single-use/TTL answer. Only rate-limit and 5xx are worth a bounded retry —
380
+ // the entry is durable until claimed or expired.
381
+ if (res.ok || res.status === 404 || (res.status < 500 && res.status !== 429))
382
+ break;
383
+ if (attempt >= attempts)
384
+ break;
385
+ await sleep(retryDelayMs);
386
+ }
387
+ if (res.status === 404) {
388
+ throw new Error('this pickup code has expired or was already used — codes are single-use and live ' +
389
+ 'about 10 minutes. Ask the owner to start a fresh mandate in the panel');
390
+ }
391
+ if (!res.ok) {
392
+ const body = (await res.json().catch(() => ({})));
393
+ throw new Error(`could not redeem the pickup code (${res.status})` + (body.error ? `: ${body.error}` : ''));
394
+ }
395
+ const doc = (await res.json().catch(() => null));
396
+ if (doc?.status === 'declined')
397
+ throw new HostedApprovalDeclinedError();
398
+ if (doc?.status !== 'completed') {
399
+ // A panel-issued pickup code is parked already-completed (the owner approved
400
+ // in the panel before the code existed). `pending` means this verifier came
401
+ // from an in-flight runner ceremony, not a mandate handoff — redeeming it
402
+ // here would race the runner that owns it.
403
+ throw new Error('this code is not a completed mandate handoff — it looks like an in-progress approval. ' +
404
+ 'Use the pickup code the panel displayed after the owner approved the mandate');
405
+ }
406
+ const ctx = doc.context ?? {};
407
+ if (ctx.budget !== true) {
408
+ throw pickupMismatch('budget', 'the approval is a one-purchase approval, not a spend budget');
409
+ }
410
+ if (ctx.agentJkt !== agentJkt) {
411
+ throw pickupMismatch('agentJkt', 'the owner approved this mandate for a different agent key than this runtime holds');
412
+ }
413
+ if (ctx.tokenId !== tokenId) {
414
+ throw pickupMismatch('tokenId', "the mandate was approved against a different enrolled card than this runtime's card " +
415
+ 'authority points at (the owner may have re-enrolled a card — re-run grant-card first)');
416
+ }
417
+ const ceiling = ctx.amount;
418
+ if (typeof ceiling !== 'string' || !DECIMAL_AMOUNT_SHAPE.test(ceiling) || Number(ceiling) <= 0) {
419
+ throw pickupMismatch('amount', 'the approved ceiling is missing or malformed');
420
+ }
421
+ const currency = ctx.currency;
422
+ if (typeof currency !== 'string' || !/^[A-Z]{3}$/.test(currency)) {
423
+ throw pickupMismatch('currency', 'the approved currency is missing or malformed');
424
+ }
425
+ const maxDraws = ctx.maxDraws;
426
+ if (!Number.isSafeInteger(maxDraws) || maxDraws < 1) {
427
+ throw pickupMismatch('maxDraws', 'the approved purchase count is missing or malformed');
428
+ }
429
+ const perTransaction = ctx.perTransaction;
430
+ if (perTransaction !== undefined) {
431
+ if (typeof perTransaction !== 'string' ||
432
+ !DECIMAL_AMOUNT_SHAPE.test(perTransaction) ||
433
+ Number(perTransaction) <= 0 ||
434
+ Number(perTransaction) > Number(ceiling)) {
435
+ throw pickupMismatch('perTransaction', 'the approved per-purchase cap is malformed');
436
+ }
437
+ }
438
+ const merchantName = ctx.merchantName;
439
+ const merchantUrl = ctx.merchantUrl;
440
+ const merchantCountryCode = ctx.merchantCountryCode;
441
+ if (typeof merchantName !== 'string' ||
442
+ !merchantName.trim() ||
443
+ typeof merchantUrl !== 'string' ||
444
+ !merchantUrl.trim() ||
445
+ typeof merchantCountryCode !== 'string' ||
446
+ !merchantCountryCode.trim()) {
447
+ throw pickupMismatch('merchant', 'the approved budget target is missing or malformed');
448
+ }
449
+ if (typeof doc.mintToken !== 'string' || !doc.mintToken) {
450
+ throw new Error('the mandate handoff carried no mint token — the deployment that parked it ran the ' +
451
+ 'dev-auth stub, which cannot mint draw authority. Re-initiate against a real deployment');
452
+ }
453
+ if (!Number.isSafeInteger(doc.validUntil) ||
454
+ doc.validUntil <= Math.floor(now() / 1000)) {
455
+ throw new Error('the mandate handoff carried no valid future expiry — ask the owner to re-initiate');
456
+ }
457
+ // Same exemption contract as runHostedApproval: only the server's explicit
458
+ // code-verified-card marker excuses a missing assurance payload.
459
+ const assuranceExempt = doc.cardholderVerification === 'otp' || doc.cardholderVerification === 'none';
460
+ if ((doc.assuranceData === undefined || doc.assuranceData === null) && !assuranceExempt) {
461
+ throw new Error('the mandate handoff completed but carried no assuranceData');
462
+ }
463
+ return {
464
+ assuranceData: doc.assuranceData ?? null,
465
+ assuranceExempt,
466
+ mintToken: doc.mintToken,
467
+ validUntil: doc.validUntil,
468
+ ceiling,
469
+ currency,
470
+ maxDraws: maxDraws,
471
+ ...(perTransaction !== undefined ? { perTransaction: perTransaction } : {}),
472
+ merchant: {
473
+ name: merchantName,
474
+ url: merchantUrl,
475
+ countryCode: merchantCountryCode,
476
+ },
477
+ };
478
+ }