@visa/cli 4.1.0-rc.29 → 4.1.0-rc.291

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.
Files changed (94) hide show
  1. package/README.md +310 -46
  2. package/dist/checkout-engine/adapters/generic.d.ts +69 -0
  3. package/dist/checkout-engine/adapters/generic.js +383 -58
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +98 -0
  7. package/dist/checkout-engine/adapters/shopify.js +744 -0
  8. package/dist/checkout-engine/amount.d.ts +17 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/browser-launch.d.ts +9 -4
  11. package/dist/checkout-engine/browser-launch.js +19 -4
  12. package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
  13. package/dist/checkout-engine/browserbase-browser.js +186 -0
  14. package/dist/checkout-engine/cli-engine.d.ts +241 -32
  15. package/dist/checkout-engine/cli-engine.js +960 -222
  16. package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
  17. package/dist/checkout-engine/confirmed-merchants.js +165 -0
  18. package/dist/checkout-engine/detect.d.ts +1 -1
  19. package/dist/checkout-engine/detect.js +6 -0
  20. package/dist/checkout-engine/evidence.d.ts +1 -1
  21. package/dist/checkout-engine/executor.d.ts +92 -4
  22. package/dist/checkout-engine/executor.js +688 -157
  23. package/dist/checkout-engine/hosted-approval.d.ts +69 -9
  24. package/dist/checkout-engine/hosted-approval.js +211 -21
  25. package/dist/checkout-engine/index.d.ts +9 -3
  26. package/dist/checkout-engine/index.js +7 -2
  27. package/dist/checkout-engine/instrument.d.ts +6 -0
  28. package/dist/checkout-engine/known-merchants.d.ts +10 -0
  29. package/dist/checkout-engine/known-merchants.js +38 -0
  30. package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
  31. package/dist/checkout-engine/live-fill-approval.js +20 -34
  32. package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
  33. package/dist/checkout-engine/mandate/card-mandate.js +10 -5
  34. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
  35. package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
  36. package/dist/checkout-engine/mandate.d.ts +8 -0
  37. package/dist/checkout-engine/mandate.js +44 -9
  38. package/dist/checkout-engine/receipt-dir.d.ts +6 -0
  39. package/dist/checkout-engine/receipt-dir.js +8 -0
  40. package/dist/checkout-engine/receipt.d.ts +56 -2
  41. package/dist/checkout-engine/receipt.js +55 -16
  42. package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
  43. package/dist/checkout-engine/shopify-primary-domain.js +96 -0
  44. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  45. package/dist/checkout-engine/trace-handles.js +12 -0
  46. package/dist/checkout-engine/types.d.ts +15 -2
  47. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  48. package/dist/checkout-engine/unresolved-charges.js +134 -0
  49. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +78 -7
  50. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +342 -27
  51. package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
  52. package/dist/checkout-engine/vgs-live-instrument.js +14 -74
  53. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  54. package/dist/checkout-engine/vic-confirmation.js +9 -3
  55. package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
  56. package/dist/checkout-engine/web-bot-auth.js +218 -0
  57. package/dist/cli.js +936 -389
  58. package/dist/managed-runtime/resolve-and-update.mjs +268 -0
  59. package/dist/managed-runtime/runtime-readiness.mjs +126 -0
  60. package/dist/managed-runtime/update-and-restart.mjs +1079 -0
  61. package/dist/mcp-apps/ucp-checkout.html +280 -0
  62. package/dist/mcp-server/index.js +763 -257
  63. package/dist/merchant-ucp-mcp/index.js +7 -0
  64. package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
  65. package/dist/skills/pair-visa-agent/SKILL.md +434 -318
  66. package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
  67. package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
  68. package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
  69. package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
  70. package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
  71. package/dist/subway-direct.mjs +1 -0
  72. package/install.ps1 +7 -6
  73. package/install.sh +3 -3
  74. package/native/bin/darwin-arm64/visa-runtime-signer +0 -0
  75. package/native/bin/darwin-x64/visa-runtime-signer +0 -0
  76. package/native/bin/linux-arm64/visa-runtime-signer +0 -0
  77. package/native/bin/linux-x64/visa-runtime-signer +0 -0
  78. package/native/bin/win32-arm64/visa-runtime-signer.exe +0 -0
  79. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  80. package/native/bin/win32-x64/visa-runtime-signer.exe +0 -0
  81. package/package.json +33 -29
  82. package/server.json +4 -4
  83. package/dist/checkout-engine/inline-target.d.ts +0 -13
  84. package/dist/checkout-engine/inline-target.js +0 -37
  85. package/dist/checkout-engine/pay-args.d.ts +0 -14
  86. package/dist/checkout-engine/pay-args.js +0 -44
  87. package/dist/checkout-engine/pay.d.ts +0 -1
  88. package/dist/checkout-engine/pay.js +0 -13
  89. package/dist/checkout-engine/repo-env.d.ts +0 -11
  90. package/dist/checkout-engine/repo-env.js +0 -23
  91. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  92. package/dist/checkout-engine/run-live-fill.js +0 -493
  93. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  94. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -1,3 +1,4 @@
1
+ import { createDecipheriv, createHash, verify, createPublicKey, diffieHellman, hkdfSync, } from 'node:crypto';
1
2
  // Server-side mint client (Phase 1) — the checkout runner mints the payment
2
3
  // credential by calling the verify-web deployment's gateway routes with a
3
4
  // short-lived, purpose-scoped MINT TOKEN, instead of holding the shared VGS
@@ -11,6 +12,7 @@
11
12
  // Request derivation here mirrors ../fetch-credential.mjs EXACTLY (mandate cap,
12
13
  // consumer prompt, PENDING retry cadence) — the only change is the transport:
13
14
  // verify-web routes + Bearer mint token, never the VGS gateway + VGS secret.
15
+ import { traceHandleFields } from '../trace-handles.js';
14
16
  const defaultSleep = (ms) => new Promise((r) => {
15
17
  const t = setTimeout(r, ms);
16
18
  t.unref?.();
@@ -39,10 +41,100 @@ export function merchantOrigin(url) {
39
41
  return url;
40
42
  }
41
43
  }
42
- /** Read a stable, non-secret error message from a route's JSON body. */
44
+ /**
45
+ * A definitive refusal from the cryptogram mint route — the request will not
46
+ * succeed if retried unchanged.
47
+ *
48
+ * `terminal` is the load-bearing field. Draw terminality used to be decided by
49
+ * regex over the error MESSAGE, which made it hostage to text nobody controls:
50
+ * this client now interpolates the provider's own `upstream_codes` into the
51
+ * message for diagnosability, and a provider identifier such as
52
+ * `request_timeout` would match the transient matcher's unanchored
53
+ * `tim(e|ed)-out` alternative — flipping a permanent 422 back to "transient",
54
+ * skipping markUnhonored, and stranding the mandate in the exact retry-forever
55
+ * loop this whole change exists to kill. Terminality is therefore carried
56
+ * structurally, and consumers must consult it BEFORE any message matching.
57
+ */
58
+ export class ServerCryptogramRefusedError extends Error {
59
+ status;
60
+ terminal = true;
61
+ constructor(status, detail) {
62
+ super(`server cryptogram refused (${status}): ${detail}`);
63
+ this.name = 'ServerCryptogramRefusedError';
64
+ this.status = status;
65
+ }
66
+ }
67
+ async function readRouteErrorDoc(res) {
68
+ return (await res.json().catch(() => null));
69
+ }
70
+ function routeErrorMessage(res, doc) {
71
+ const base = doc?.error || doc?.error_code || `HTTP ${res.status}`;
72
+ // Carry the provider's own status and rejected-attribute identifiers into the
73
+ // message when the route reflected them. Without this the operator sees only
74
+ // our status and has to go log-diving to learn WHICH field the gateway
75
+ // objected to — the gap that left a live create_intent 422 undiagnosable.
76
+ const parts = [];
77
+ if (typeof doc?.upstream_status === 'number')
78
+ parts.push(`upstream_status=${doc.upstream_status}`);
79
+ if (Array.isArray(doc?.upstream_codes) && doc.upstream_codes.length > 0) {
80
+ parts.push(`upstream_codes=${doc.upstream_codes.join(',')}`);
81
+ }
82
+ return parts.length > 0 ? `${base} [${parts.join(' ')}]` : base;
83
+ }
43
84
  async function routeError(res) {
44
- const doc = (await res.json().catch(() => null));
45
- return doc?.error || doc?.error_code || `HTTP ${res.status}`;
85
+ return routeErrorMessage(res, await readRouteErrorDoc(res));
86
+ }
87
+ const BOOTSTRAP_STATES = new Set([
88
+ 'none',
89
+ 'pending',
90
+ 'created',
91
+ 'ambiguous',
92
+ 'conflict',
93
+ 'unknown',
94
+ ]);
95
+ function bootstrapStateOf(value) {
96
+ return typeof value === 'string' && BOOTSTRAP_STATES.has(value)
97
+ ? value
98
+ : 'unknown';
99
+ }
100
+ const SAFE_REQUEST_ID = /^[A-Za-z0-9._:-]{1,128}$/;
101
+ function requestIdOf(res, doc) {
102
+ const candidate = res?.headers.get('x-request-id') ?? doc?.request_id ?? null;
103
+ return typeof candidate === 'string' && SAFE_REQUEST_ID.test(candidate) ? candidate : null;
104
+ }
105
+ /**
106
+ * The budget-intent route's failure, kept structured (#8470). `status` 0 means
107
+ * the request never produced an HTTP response (network failure or abort), which
108
+ * is outcome-uncertain exactly like a 5xx: the server may have dispatched.
109
+ */
110
+ export class ServerIntentError extends Error {
111
+ facts;
112
+ code = 'SERVER_INTENT_FAILED';
113
+ constructor(message, facts) {
114
+ super(message);
115
+ this.facts = facts;
116
+ this.name = 'ServerIntentError';
117
+ }
118
+ }
119
+ function serverIntentErrorFrom(res, doc) {
120
+ const errorCode = typeof doc?.error_code === 'string'
121
+ ? doc.error_code
122
+ : typeof doc?.error === 'string' && /^[a-z0-9_]{1,64}$/.test(doc.error)
123
+ ? doc.error
124
+ : null;
125
+ const outcome = doc?.outcome === 'uncertain' || doc?.outcome === 'not_created'
126
+ ? doc.outcome
127
+ : res.status >= 500 || res.status === 0
128
+ ? 'uncertain'
129
+ : 'not_created';
130
+ return new ServerIntentError(`server intent failed (${res.status}): ${routeErrorMessage(res, doc)}`, {
131
+ status: res.status,
132
+ errorCode,
133
+ retryable: doc?.retryable === true,
134
+ requestId: requestIdOf(res, doc),
135
+ bootstrapState: bootstrapStateOf(doc?.bootstrap_state),
136
+ outcome,
137
+ });
46
138
  }
47
139
  function bearer(mintToken) {
48
140
  return { 'content-type': 'application/json', authorization: `Bearer ${mintToken}` };
@@ -64,34 +156,92 @@ export async function serverCreateIntent(base, mintToken, input, deps = {}) {
64
156
  const effectiveUntil = override.effectiveUntil ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
65
157
  const consumerPrompt = override.consumerPrompt ??
66
158
  `Buy an item from ${t.merchantName} for ${t.transactionCurrencyCode.toUpperCase()} ${t.transactionAmount}`;
67
- const res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
68
- method: 'POST',
69
- headers: bearer(mintToken),
70
- body: JSON.stringify({
71
- tokenId,
72
- consumerPrompt,
73
- assuranceData,
74
- mandates: [
75
- {
76
- description: `Purchase at ${t.merchantName}`,
77
- declineThresholdAmount,
78
- declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
79
- effectiveUntil,
80
- merchantCategory: 'Retail',
81
- merchantCategoryCode: '5999',
82
- preferredMerchantName: t.merchantName,
83
- quantity,
84
- },
85
- ],
86
- }),
87
- });
159
+ let res;
160
+ try {
161
+ res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
162
+ method: 'POST',
163
+ headers: bearer(mintToken),
164
+ body: JSON.stringify({
165
+ tokenId,
166
+ consumerPrompt,
167
+ assuranceData,
168
+ mandates: [
169
+ {
170
+ description: `Purchase at ${t.merchantName}`,
171
+ declineThresholdAmount,
172
+ declineThresholdCurrencyCode: t.transactionCurrencyCode.toUpperCase(),
173
+ effectiveUntil,
174
+ merchantCategory: 'Retail',
175
+ merchantCategoryCode: '5999',
176
+ preferredMerchantName: t.merchantName,
177
+ quantity,
178
+ },
179
+ ],
180
+ }),
181
+ });
182
+ }
183
+ catch (err) {
184
+ // No HTTP response at all: the request may or may not have reached the
185
+ // server, so this is outcome-uncertain, never a proven non-creation.
186
+ throw new ServerIntentError(`server intent failed (0): ${err instanceof Error ? err.message : String(err)}`, {
187
+ status: 0,
188
+ errorCode: null,
189
+ retryable: false,
190
+ requestId: null,
191
+ bootstrapState: 'unknown',
192
+ outcome: 'uncertain',
193
+ });
194
+ }
88
195
  if (!res.ok)
89
- throw new Error(`server intent failed (${res.status}): ${await routeError(res)}`);
196
+ throw serverIntentErrorFrom(res, await readRouteErrorDoc(res));
90
197
  const doc = (await res.json().catch(() => null));
91
198
  if (!doc?.intentId)
92
199
  throw new Error('server intent response missing intentId');
93
200
  return { intentId: doc.intentId, status: typeof doc.status === 'string' ? doc.status : null };
94
201
  }
202
+ /**
203
+ * Read the durable budget-bootstrap state for a budget mint token via
204
+ * GET {base}/api/vgs/intent (#8470). A `created` answer carries the intent the
205
+ * server already minted under this token, so the runner registers it instead
206
+ * of dispatching again; `pending` and `ambiguous` are never redispatched.
207
+ */
208
+ export async function serverReadIntentBootstrap(base, mintToken, deps = {}) {
209
+ const fetchImpl = deps.fetchImpl ?? fetch;
210
+ let res;
211
+ try {
212
+ res = await fetchImpl(`${stripTrailingSlashes(base)}/api/vgs/intent`, {
213
+ method: 'GET',
214
+ headers: bearer(mintToken),
215
+ });
216
+ }
217
+ catch (err) {
218
+ throw new ServerIntentError(`server intent status failed (0): ${err instanceof Error ? err.message : String(err)}`, {
219
+ status: 0,
220
+ errorCode: null,
221
+ retryable: true,
222
+ requestId: null,
223
+ bootstrapState: 'unknown',
224
+ outcome: 'unknown',
225
+ });
226
+ }
227
+ const doc = (await res.json().catch(() => null));
228
+ if (!res.ok) {
229
+ const error = serverIntentErrorFrom(res, doc);
230
+ throw new ServerIntentError(error.message.replace('server intent failed', 'server intent status failed'), {
231
+ ...error.facts,
232
+ retryable: error.facts.retryable || res.status === 503,
233
+ outcome: 'unknown',
234
+ });
235
+ }
236
+ const state = bootstrapStateOf(doc?.bootstrap_state);
237
+ const intentId = typeof doc?.intent?.intentId === 'string' && doc.intent.intentId ? doc.intent.intentId : null;
238
+ return {
239
+ state: state === 'created' && !intentId ? 'unknown' : state,
240
+ intentId,
241
+ intentStatus: typeof doc?.intent?.status === 'string' ? doc.intent.status : null,
242
+ requestId: requestIdOf(res, doc),
243
+ };
244
+ }
95
245
  /**
96
246
  * Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
97
247
  * The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
@@ -144,11 +294,16 @@ export async function serverFetchCryptogram(base, mintToken, input, deps = {}) {
144
294
  ...(typeof c.cryptogramExpiresAt === 'string'
145
295
  ? { cryptogramExpiresAt: c.cryptogramExpiresAt }
146
296
  : {}),
297
+ ...traceHandleFields(c),
147
298
  };
148
299
  }
149
300
  // A 4xx (bad request / binding refusal / auth) is terminal — never retry it.
150
- if (res.status >= 400 && res.status < 500) {
151
- throw new Error(`server cryptogram refused (${res.status}): ${await routeError(res)}`);
301
+ // 429 is the sole exception: it is an explicitly retryable 4xx, and treating
302
+ // it as terminal would let a transient rate limit permanently disable a
303
+ // healthy mandate. The route maps upstream rate limits to 503 today, so this
304
+ // is a guard against a future emitter, not a live path.
305
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
306
+ throw new ServerCryptogramRefusedError(res.status, await routeError(res));
152
307
  }
153
308
  lastError = `${res.status}: ${await routeError(res)}`;
154
309
  if (attempt < attempts)
@@ -176,3 +331,163 @@ export async function serverPostConfirmation(base, mintToken, input, deps = {})
176
331
  throw new Error(`server confirmation failed (${res.status}): ${await routeError(res)}`);
177
332
  return { ok: true };
178
333
  }
334
+ function cardEvidence(token, key, typ, fields) {
335
+ const fail = () => {
336
+ throw new Error('protected_card_evidence_invalid');
337
+ };
338
+ if (key.type !== 'public' ||
339
+ key.asymmetricKeyType !== 'ed25519' ||
340
+ typeof token !== 'string' ||
341
+ token.length > 16384)
342
+ return fail();
343
+ const parts = token.split('.');
344
+ if (parts.length !== 3)
345
+ return fail();
346
+ const jwk = key.export({ format: 'jwk' });
347
+ const kid = createHash('sha256')
348
+ .update(JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x }))
349
+ .digest('base64url');
350
+ const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
351
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
352
+ if (!header ||
353
+ Object.keys(header).sort().join(',') !== 'alg,kid,typ' ||
354
+ header.alg !== 'EdDSA' ||
355
+ header.typ !== typ ||
356
+ header.kid !== kid ||
357
+ !claims ||
358
+ Object.keys(claims).sort().join(',') !== fields.sort().join(',') ||
359
+ claims.v !== 1 ||
360
+ typeof claims.jti !== 'string' ||
361
+ !claims.jti ||
362
+ !Number.isSafeInteger(claims.iat) ||
363
+ !verify(null, Buffer.from(parts.slice(0, 2).join('.')), key, Buffer.from(parts[2], 'base64url')))
364
+ return fail();
365
+ return claims;
366
+ }
367
+ export async function serverFetchProtectedCryptogram(base, input, deps = {}) {
368
+ const hash = Buffer.from(input.requestHash, 'base64url');
369
+ if (hash.length !== 32 ||
370
+ hash.toString('base64url') !== input.requestHash ||
371
+ input.deliveryKey.type !== 'private' ||
372
+ input.deliveryKey.asymmetricKeyType !== 'x25519')
373
+ throw new Error('protected_card_delivery_key_invalid');
374
+ const response = await (deps.fetchImpl ?? fetch)(`${stripTrailingSlashes(base)}/api/vgs/payment-cryptogram`, {
375
+ method: 'POST',
376
+ headers: { 'content-type': 'application/json', 'x-visa-card-protected': 'v1' },
377
+ body: JSON.stringify({
378
+ financialAdmission: input.financialAdmission,
379
+ request: input.request,
380
+ }),
381
+ redirect: 'error',
382
+ });
383
+ if (response.status === 202)
384
+ throw new Error('protected_card_recovery_pending');
385
+ if (!response.ok)
386
+ throw new Error(`protected_card_refused_${response.status}`);
387
+ const reader = response.body?.getReader();
388
+ if (!reader)
389
+ throw new Error('protected_card_response_invalid');
390
+ const chunks = [];
391
+ let size = 0;
392
+ try {
393
+ for (;;) {
394
+ const part = await reader.read();
395
+ if (part.done)
396
+ break;
397
+ size += part.value.byteLength;
398
+ if (size > 65536) {
399
+ await reader.cancel();
400
+ throw new Error('protected_card_response_invalid');
401
+ }
402
+ chunks.push(part.value);
403
+ }
404
+ }
405
+ finally {
406
+ reader.releaseLock();
407
+ }
408
+ const envelope = JSON.parse(Buffer.concat(chunks).toString());
409
+ if (!envelope ||
410
+ Object.keys(envelope).sort().join(',') !==
411
+ 'acknowledgement,ciphertext,ephemeralPublicJwk,iv,outcome,tag,v' ||
412
+ envelope.v !== 1)
413
+ throw new Error('protected_card_response_invalid');
414
+ const coordinates = [
415
+ 'v',
416
+ 'aud',
417
+ 'jti',
418
+ 'attemptId',
419
+ 'requestHash',
420
+ 'admissionId',
421
+ 'issuanceGeneration',
422
+ 'iat',
423
+ ];
424
+ const outcome = cardEvidence(envelope.outcome, input.pins.executor, 'visa-card-delivery-outcome+jwt', [...coordinates, 'outcome', 'providerRef', 'credentialSha256']);
425
+ const ack = cardEvidence(envelope.acknowledgement, input.pins.issuer, 'visa-card-delivery-state+jwt', [...coordinates, 'action', 'outcomeHash', 'exp']);
426
+ const permit = JSON.parse(Buffer.from(input.financialAdmission.split('.')[1], 'base64url').toString());
427
+ const now = Math.floor(Date.now() / 1000);
428
+ if (outcome.aud !== 'urn:visa:authority:card-outcome' ||
429
+ outcome.outcome !== 'credential_observed' ||
430
+ outcome.requestHash !== input.requestHash ||
431
+ outcome.attemptId !== permit.attemptId ||
432
+ outcome.admissionId !== permit.jti ||
433
+ outcome.issuanceGeneration !== permit.issuanceGeneration ||
434
+ ack.aud !== 'urn:visa:executor:card-vic' ||
435
+ ack.action !== 'recover_only' ||
436
+ ack.requestHash !== outcome.requestHash ||
437
+ ack.attemptId !== outcome.attemptId ||
438
+ ack.admissionId !== outcome.admissionId ||
439
+ ack.issuanceGeneration !== outcome.issuanceGeneration ||
440
+ !Number.isSafeInteger(ack.exp) ||
441
+ ack.exp <= now ||
442
+ ack.iat > now + 5 ||
443
+ ack.exp <= ack.iat ||
444
+ ack.exp - ack.iat > 60 ||
445
+ ack.outcomeHash !== createHash('sha256').update(envelope.outcome).digest('base64url'))
446
+ throw new Error('protected_card_evidence_invalid');
447
+ const jwk = envelope.ephemeralPublicJwk;
448
+ if (!jwk ||
449
+ Object.keys(jwk).sort().join(',') !== 'crv,kty,x' ||
450
+ jwk.kty !== 'OKP' ||
451
+ jwk.crv !== 'X25519')
452
+ throw new Error('protected_card_response_invalid');
453
+ const decode = (value, size) => {
454
+ if (typeof value !== 'string')
455
+ throw new Error('protected_card_response_invalid');
456
+ const decoded = Buffer.from(value, 'base64url');
457
+ if (decoded.toString('base64url') !== value || (size !== undefined && decoded.length !== size))
458
+ throw new Error('protected_card_response_invalid');
459
+ return decoded;
460
+ };
461
+ decode(jwk.x, 32);
462
+ const shared = diffieHellman({
463
+ privateKey: input.deliveryKey,
464
+ publicKey: createPublicKey({ key: jwk, format: 'jwk' }),
465
+ });
466
+ const key = Buffer.from(hkdfSync('sha256', shared, hash, 'visa-card-delivery-v1', 32));
467
+ shared.fill(0);
468
+ let clear;
469
+ try {
470
+ const decipher = createDecipheriv('aes-256-gcm', key, decode(envelope.iv, 12));
471
+ decipher.setAAD(Buffer.from(input.requestHash));
472
+ decipher.setAuthTag(decode(envelope.tag, 16));
473
+ clear = Buffer.concat([decipher.update(decode(envelope.ciphertext)), decipher.final()]);
474
+ if (createHash('sha256').update(clear).digest('base64url') !== outcome.credentialSha256)
475
+ throw new Error('protected_card_credential_invalid');
476
+ const credential = JSON.parse(clear.toString());
477
+ if (!credential ||
478
+ typeof credential.networkToken !== 'string' ||
479
+ typeof credential.cryptogramValue !== 'string' ||
480
+ !Number.isInteger(credential.expMonth) ||
481
+ credential.expMonth < 1 ||
482
+ credential.expMonth > 12 ||
483
+ !Number.isInteger(credential.expYear) ||
484
+ credential.expYear < 2000 ||
485
+ typeof credential.cryptogramType !== 'string')
486
+ throw new Error('protected_card_credential_invalid');
487
+ return credential;
488
+ }
489
+ finally {
490
+ key.fill(0);
491
+ clear?.fill(0);
492
+ }
493
+ }
@@ -42,6 +42,15 @@ export type PurchaseAssurance = {
42
42
  transactionAmount: string;
43
43
  /** ISO 4217 currency the ceremony was scoped to. */
44
44
  transactionCurrencyCode: string;
45
+ /**
46
+ * True iff the approval server marked this token passkey-exempt ('otp'
47
+ * cardholder ID&V / 'none' — verified against the owner's account card
48
+ * record at complete-time): no ceremony ran, `assuranceData` is null by
49
+ * design, and the intent is minted without it (the signed mint token
50
+ * carries the same exemption for the server's own check). Absent/false →
51
+ * assuranceData is REQUIRED, exactly the historical contract.
52
+ */
53
+ assuranceExempt?: boolean;
45
54
  };
46
55
  /**
47
56
  * Our fail-closed freshness bound, matching the runner's 15-minute checkout
@@ -57,6 +66,8 @@ export type VgsPaymentCredential = {
57
66
  cryptogramType: string;
58
67
  cryptogramValue: string;
59
68
  cryptogramExpiresAt?: string;
69
+ vgsTraceId?: string;
70
+ networkCorrelationId?: string;
60
71
  };
61
72
  export type FetchVgsPaymentCredential = (input: {
62
73
  tokenId: string;
@@ -69,14 +80,6 @@ export type FetchVgsPaymentCredential = (input: {
69
80
  transactionCurrencyCode: string;
70
81
  };
71
82
  }) => Promise<VgsPaymentCredential>;
72
- export type MintFreshVgsPaymentCredential = (input: {
73
- tokenId: string;
74
- assuranceData: unknown;
75
- transaction: VgsCheckoutTarget;
76
- }) => Promise<{
77
- payment: VgsPaymentCredential;
78
- intentId: string;
79
- }>;
80
83
  /** The (token, intent) pair a VIC confirmation is posted against. */
81
84
  export type VicConfirmationTarget = {
82
85
  tokenId: string;
@@ -139,30 +142,3 @@ export declare class VgsLiveInstrument implements Instrument {
139
142
  confirmationTarget(): VicConfirmationTarget | null;
140
143
  getCredential(ctx: InstrumentContext): Promise<CardCredential>;
141
144
  }
142
- /**
143
- * Consumes #5614's claimed CLI enrollment artifact (for the tokenId) plus a
144
- * FRESH purchase-scoped assurance, and creates a fresh, transaction-scoped VIC
145
- * intent + credential after checkout approval. This is the production-shaped
146
- * bridge; unlike VgsLiveInstrument it never needs a pre-created intent ID.
147
- *
148
- * The enrollment artifact's stored assuranceData is deliberately never sent:
149
- * replaying it makes intent creation succeed (HTTP 201) while the cryptogram
150
- * deterministically never completes — the #5709 dead end. Purchase
151
- * authorization is the fresh, merchant+amount+currency-scoped assurance,
152
- * validated against the checkout target BEFORE any intent is minted so a
153
- * doomed intent is never created.
154
- */
155
- export declare class VgsAssuranceInstrument implements Instrument {
156
- private readonly enrollment;
157
- private readonly purchase;
158
- private readonly target;
159
- private readonly cardholderName;
160
- private readonly mintCredential;
161
- readonly kind: "agentic-token";
162
- private used;
163
- private minted;
164
- constructor(enrollment: CliAgentCredential, purchase: PurchaseAssurance, target: VgsCheckoutTarget, cardholderName: string, mintCredential: MintFreshVgsPaymentCredential);
165
- /** See VgsLiveInstrument.confirmationTarget — same contract. */
166
- confirmationTarget(): VicConfirmationTarget | null;
167
- getCredential(ctx: InstrumentContext): Promise<CardCredential>;
168
- }
@@ -1,3 +1,4 @@
1
+ import { traceHandleFields } from './trace-handles.js';
1
2
  /**
2
3
  * Our fail-closed freshness bound, matching the runner's 15-minute checkout
3
4
  * review window — NOT a claim about VGS's actual assurance TTL (unpublished).
@@ -86,13 +87,6 @@ function validateTarget(reference, ctx) {
86
87
  throw new Error(`VGS credential currency mismatch: ${currency} vs ${ctx.currency}`);
87
88
  }
88
89
  }
89
- function validateCliCredential(value) {
90
- if (typeof value.tokenId !== 'string' || !value.tokenId.trim()) {
91
- throw new Error('CLI agent credential requires tokenId');
92
- }
93
- // Deliberately no assuranceData requirement: the artifact's enrollment-time
94
- // assurance is identity/enrollment material and is never read here (#5709).
95
- }
96
90
  /**
97
91
  * Refuse to mint an intent unless the assurance is fresh and its declared
98
92
  * scope matches the checkout target exactly. `now` is injectable so the
@@ -105,7 +99,10 @@ export function validatePurchaseAssurance(value, target, now = new Date()) {
105
99
  if (value == null || typeof value !== 'object') {
106
100
  throw new Error(`purchase assurance is missing — ${remedy}`);
107
101
  }
108
- if (value.assuranceData == null) {
102
+ // A passkey-exempt approval (marked by the approval SERVER, never assumed
103
+ // locally) carries no assurance by design — every scope/freshness check
104
+ // below still applies to it unchanged.
105
+ if (value.assuranceData == null && value.assuranceExempt !== true) {
109
106
  throw new Error(`purchase assurance requires assuranceData — ${remedy}`);
110
107
  }
111
108
  const mintedAtMs = typeof value.mintedAt === 'string' ? Date.parse(value.mintedAt) : NaN;
@@ -162,13 +159,14 @@ export function validateCredential(value, now = new Date()) {
162
159
  (value.expYear === now.getFullYear() && value.expMonth < now.getMonth() + 1)) {
163
160
  throw new Error('VGS credential is expired');
164
161
  }
165
- if (value.cryptogramExpiresAt !== undefined) {
166
- const expiresAtMs = Date.parse(value.cryptogramExpiresAt);
167
- if (!Number.isFinite(expiresAtMs))
168
- throw new Error('VGS credential expiry is invalid');
169
- if (expiresAtMs - now.getTime() < 60_000) {
170
- throw new Error('VGS credential has less than 60 seconds of validity remaining');
171
- }
162
+ if (value.cryptogramExpiresAt === undefined) {
163
+ throw new Error('VGS credential expiry is missing');
164
+ }
165
+ const expiresAtMs = Date.parse(value.cryptogramExpiresAt);
166
+ if (!Number.isFinite(expiresAtMs))
167
+ throw new Error('VGS credential expiry is invalid');
168
+ if (expiresAtMs - now.getTime() < 60_000) {
169
+ throw new Error('VGS credential has less than 60 seconds of validity remaining');
172
170
  }
173
171
  }
174
172
  /**
@@ -225,65 +223,7 @@ export class VgsLiveInstrument {
225
223
  cvc: value.cryptogramValue,
226
224
  cardholderName: this.cardholderName.trim(),
227
225
  ...(value.cryptogramExpiresAt ? { credentialExpiresAt: value.cryptogramExpiresAt } : {}),
228
- };
229
- }
230
- }
231
- /**
232
- * Consumes #5614's claimed CLI enrollment artifact (for the tokenId) plus a
233
- * FRESH purchase-scoped assurance, and creates a fresh, transaction-scoped VIC
234
- * intent + credential after checkout approval. This is the production-shaped
235
- * bridge; unlike VgsLiveInstrument it never needs a pre-created intent ID.
236
- *
237
- * The enrollment artifact's stored assuranceData is deliberately never sent:
238
- * replaying it makes intent creation succeed (HTTP 201) while the cryptogram
239
- * deterministically never completes — the #5709 dead end. Purchase
240
- * authorization is the fresh, merchant+amount+currency-scoped assurance,
241
- * validated against the checkout target BEFORE any intent is minted so a
242
- * doomed intent is never created.
243
- */
244
- export class VgsAssuranceInstrument {
245
- enrollment;
246
- purchase;
247
- target;
248
- cardholderName;
249
- mintCredential;
250
- kind = 'agentic-token';
251
- used = false;
252
- minted = null;
253
- constructor(enrollment, purchase, target, cardholderName, mintCredential) {
254
- this.enrollment = enrollment;
255
- this.purchase = purchase;
256
- this.target = target;
257
- this.cardholderName = cardholderName;
258
- this.mintCredential = mintCredential;
259
- }
260
- /** See VgsLiveInstrument.confirmationTarget — same contract. */
261
- confirmationTarget() {
262
- return this.minted;
263
- }
264
- async getCredential(ctx) {
265
- if (this.used)
266
- throw new Error('VGS assurance instrument is single-use');
267
- this.used = true;
268
- validateCliCredential(this.enrollment);
269
- validateTarget(this.target, ctx);
270
- validatePurchaseAssurance(this.purchase, this.target);
271
- if (!this.cardholderName.trim())
272
- throw new Error('cardholder name is required');
273
- const { payment: value, intentId } = await this.mintCredential({
274
- tokenId: this.enrollment.tokenId,
275
- assuranceData: this.purchase.assuranceData,
276
- transaction: this.target,
277
- });
278
- this.minted = { tokenId: this.enrollment.tokenId, intentId };
279
- validateCredential(value);
280
- return {
281
- pan: value.networkToken,
282
- expMonth: value.expMonth,
283
- expYear: value.expYear,
284
- cvc: value.cryptogramValue,
285
- cardholderName: this.cardholderName.trim(),
286
- ...(value.cryptogramExpiresAt ? { credentialExpiresAt: value.cryptogramExpiresAt } : {}),
226
+ ...traceHandleFields(value),
287
227
  };
288
228
  }
289
229
  }
@@ -12,11 +12,29 @@ export type PostVicConfirmation = (input: {
12
12
  transactionCurrencyCode: string;
13
13
  };
14
14
  }) => Promise<unknown>;
15
+ /**
16
+ * Why a confirmation was not posted, as a BOUNDED value rather than prose.
17
+ *
18
+ * `reason` below is a human sentence built for the local receipt; it embeds
19
+ * upstream error text and is useless as an aggregate. These three codes are the
20
+ * whole space, and they mean very different things:
21
+ *
22
+ * - `outcome_not_definitive` — the merchant never gave a definitive answer, so
23
+ * there is nothing truthful to report. Expected, not a fault.
24
+ * - `no_credential_minted` — no VIC credential existed for this run, so there
25
+ * is no intent to confirm against. Expected, not a fault.
26
+ * - `post_failed` — we HAD a definitive outcome and an intent to report it
27
+ * against, and the POST did not land. This one is a fault: the processor
28
+ * lifecycle row is never written, so the purchase can never rank above the
29
+ * platform's own record no matter how well it actually went.
30
+ */
31
+ export type VicConfirmationSkipCode = 'outcome_not_definitive' | 'no_credential_minted' | 'post_failed';
15
32
  export type VicConfirmationReport = {
16
33
  posted: true;
17
34
  transactionStatus: VicTransactionStatus;
18
35
  } | {
19
36
  posted: false;
37
+ code: VicConfirmationSkipCode;
20
38
  reason: string;
21
39
  };
22
40
  /** Definitive merchant answers map to a status; everything else maps to none. */
@@ -11,12 +11,17 @@ export async function reportVicOutcome(input) {
11
11
  if (!transactionStatus) {
12
12
  return {
13
13
  posted: false,
14
+ code: 'outcome_not_definitive',
14
15
  reason: `outcome '${input.outcome}' is not a definitive merchant answer — ` +
15
16
  'resolve it with the merchant, then post the confirmation manually',
16
17
  };
17
18
  }
18
19
  if (!input.target) {
19
- return { posted: false, reason: 'no VIC credential was minted in this run' };
20
+ return {
21
+ posted: false,
22
+ code: 'no_credential_minted',
23
+ reason: 'no VIC credential was minted in this run',
24
+ };
20
25
  }
21
26
  try {
22
27
  await input.post({
@@ -32,8 +37,9 @@ export async function reportVicOutcome(input) {
32
37
  catch (err) {
33
38
  return {
34
39
  posted: false,
35
- reason: `confirmation POST failed: ${err.message} — the merchant outcome stands; ` +
36
- 'retry the confirmation for this intent out-of-band',
40
+ code: 'post_failed',
41
+ reason: `confirmation POST failed: ${err.message} — the merchant-reported outcome ` +
42
+ 'remains unverified; retry the confirmation for this intent out-of-band',
37
43
  };
38
44
  }
39
45
  }