@visa/cli 4.1.0-rc.26 → 4.1.0-rc.260

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 (83) hide show
  1. package/README.md +202 -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 +80 -0
  7. package/dist/checkout-engine/adapters/shopify.js +688 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -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 +206 -32
  15. package/dist/checkout-engine/cli-engine.js +772 -216
  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 +61 -3
  22. package/dist/checkout-engine/executor.js +550 -144
  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 +6 -2
  26. package/dist/checkout-engine/index.js +5 -1
  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/receipt-dir.d.ts +6 -0
  37. package/dist/checkout-engine/receipt-dir.js +8 -0
  38. package/dist/checkout-engine/receipt.d.ts +56 -2
  39. package/dist/checkout-engine/receipt.js +55 -16
  40. package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
  41. package/dist/checkout-engine/shopify-primary-domain.js +96 -0
  42. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  43. package/dist/checkout-engine/trace-handles.js +12 -0
  44. package/dist/checkout-engine/types.d.ts +15 -2
  45. package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
  46. package/dist/checkout-engine/unresolved-charges.js +134 -0
  47. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
  48. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
  49. package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
  50. package/dist/checkout-engine/vgs-live-instrument.js +14 -74
  51. package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
  52. package/dist/checkout-engine/vic-confirmation.js +9 -3
  53. package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
  54. package/dist/checkout-engine/web-bot-auth.js +218 -0
  55. package/dist/cli.js +899 -387
  56. package/dist/mcp-apps/ucp-checkout.html +280 -0
  57. package/dist/mcp-server/index.js +725 -254
  58. package/dist/merchant-ucp-mcp/index.js +6 -0
  59. package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
  60. package/dist/skills/pair-visa-agent/SKILL.md +433 -318
  61. package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
  62. package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
  63. package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
  64. package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
  65. package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
  66. package/dist/subway-direct.mjs +1 -0
  67. package/install.ps1 +7 -6
  68. package/install.sh +3 -3
  69. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  70. package/package.json +31 -28
  71. package/server.json +4 -4
  72. package/dist/checkout-engine/inline-target.d.ts +0 -13
  73. package/dist/checkout-engine/inline-target.js +0 -37
  74. package/dist/checkout-engine/pay-args.d.ts +0 -14
  75. package/dist/checkout-engine/pay-args.js +0 -44
  76. package/dist/checkout-engine/pay.d.ts +0 -1
  77. package/dist/checkout-engine/pay.js +0 -13
  78. package/dist/checkout-engine/repo-env.d.ts +0 -11
  79. package/dist/checkout-engine/repo-env.js +0 -23
  80. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  81. package/dist/checkout-engine/run-live-fill.js +0 -493
  82. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  83. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -248
@@ -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: {
@@ -49,7 +51,7 @@ export type CreateCardMandateDeps = {
49
51
  ledger: MandateLedger;
50
52
  /** verify-web origin the intent is minted against (persisted for later draws). */
51
53
  approvalBaseUrl: string;
52
- /** Scoped mint token to persist for tap-free draws. */
54
+ /** @deprecated Accepted from older callers but deliberately never persisted. */
53
55
  mintToken?: string;
54
56
  now?: () => Date;
55
57
  };
@@ -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
@@ -18,7 +18,7 @@
18
18
  // stays FAIL-CLEAN as defense-in-depth: the budget is decremented only after a
19
19
  // payable cryptogram returns; a network rejection leaves the budget untouched.
20
20
  // The caller (cli-engine) then marks the mandate unhonored so the NEXT
21
- // pay_merchant skips it and takes a fresh per-purchase tap — the same call is
21
+ // pay_merchant skips it and surfaces the no-covering refusal (#7348) — the same call is
22
22
  // not retried in-flight. Never fail-open.
23
23
  import { decimalToMinor, minorToDecimal, validateCredential, } from '../vgs-live-instrument.js';
24
24
  import { CARD_MANDATE_LEDGER_VERSION, remainingMinor, } from './mandate-ledger.js';
@@ -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,
@@ -119,13 +123,13 @@ export async function createCardMandate(input, deps) {
119
123
  merchantHost: host,
120
124
  merchantCountryCode: input.merchant.countryCode.toUpperCase(),
121
125
  approvalBaseUrl: deps.approvalBaseUrl,
122
- ...(deps.mintToken ? { mintToken: deps.mintToken } : {}),
123
126
  expiresAt: new Date(expiryMs).toISOString(),
127
+ maxDraws,
124
128
  createdAt: now.toISOString(),
125
129
  draws: [],
126
130
  ...(input.crossMerchant ? { crossMerchant: true } : {}),
127
131
  };
128
- await deps.ledger.create(record);
132
+ await deps.ledger.create(record, now);
129
133
  return {
130
134
  mandateId: record.mandateId,
131
135
  ceilingMinor: record.ceilingMinor,
@@ -133,12 +137,13 @@ export async function createCardMandate(input, deps) {
133
137
  currencyCode,
134
138
  merchant: input.merchant,
135
139
  expiresAt: record.expiresAt,
140
+ maxDraws,
136
141
  };
137
142
  }
138
143
  const DRAW_REMEDY = 'the card-mandate draw did not complete — a ceiling-scoped assurance may not be honored ' +
139
144
  'for this sub-amount draw. The budget is untouched. If this was a transient network error ' +
140
145
  '(gateway 5xx / try again), retry — the mandate stays active; a persistent decline disables ' +
141
- 'the mandate and your next pay_merchant uses a fresh per-purchase tap';
146
+ 'the mandate; a v4 card grant then requires a new mandate before another checkout';
142
147
  /**
143
148
  * Thrown ONLY for the post-reservation network/validation rejection class — the
144
149
  * cryptogram fetch failed or returned a non-payable credential (the DRAW_REMEDY
@@ -180,7 +185,7 @@ export async function drawFromMandate(input, deps) {
180
185
  `${JSON.stringify(input.transaction.transactionAmount)} (minor units)`);
181
186
  }
182
187
  // Currency must always match; merchant must match UNLESS this is a budget
183
- // (crossMerchant) mandate the owner approved for any merchant. The network
188
+ // (crossMerchant) mandate the owner approved for eligible retail merchants. The network
184
189
  // honors a ceiling-scoped cryptogram cross-merchant, so a budget mandate is
185
190
  // deliberately not host-restricted — the ceiling + per-transaction limit still
186
191
  // 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. */
@@ -29,13 +31,11 @@ export type CardMandateRecord = {
29
31
  merchantCountryCode: string;
30
32
  /** verify-web origin the ceiling intent was minted against. */
31
33
  approvalBaseUrl: string;
32
- /**
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.
36
- */
34
+ /** @deprecated Read-migration only. New writers never persist this bearer. */
37
35
  mintToken?: string;
38
36
  expiresAt: string;
37
+ /** Network purchase-count cap disclosed at approval. Absent on legacy rows. */
38
+ maxDraws?: number;
39
39
  createdAt: string;
40
40
  draws: CardMandateDraw[];
41
41
  /**
@@ -43,7 +43,7 @@ export type CardMandateRecord = {
43
43
  * ceiling-scoped assurance the acquirer would not honor for a sub-amount
44
44
  * draw). Distinct from a reservation `status`: it disables the whole mandate.
45
45
  * Once set, findCovering() SKIPS this mandate so the caller's next
46
- * pay_merchant falls through to a fresh per-purchase tap instead of
46
+ * pay_merchant surfaces the no-covering-mandate refusal (#7348) instead of
47
47
  * re-selecting the same failing mandate forever. ISO 8601.
48
48
  */
49
49
  unhonoredAt?: string;
@@ -51,20 +51,24 @@ export type CardMandateRecord = {
51
51
  * Set when the delegated-draw register handshake FAILED at mandate-start (the
52
52
  * server-side `card_mandate_spend` row was never created), so a later
53
53
  * delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
54
- * findCovering() SKIPS a register-failed mandate so the next checkout falls
55
- * 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.
54
+ * findCovering() SKIPS a register-failed mandate so the next checkout
55
+ * surfaces the no-covering-mandate refusal (#7348) instead of a confusing
56
+ * `no_mandate`. Mandate-start requires delegated card authority, so every
57
+ * created record attempted registration. ISO 8601.
58
58
  */
59
59
  registerFailedAt?: string;
60
60
  /**
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.
61
+ * Set after an authenticated owner-scoped server read proves this mandate was
62
+ * revoked. The local file is only an execution cache; server revocation is
63
+ * canonical and permanently retires the cached mandate from selection while
64
+ * retaining its receipt history for the owner.
65
+ */
66
+ serverRevokedAt?: string;
67
+ /**
68
+ * A cross-host retail budget, not scoped to one `merchantHost`. The local
69
+ * selector may attempt it at any host, while the provider/network still
70
+ * applies the approved Retail/5999 category plus amount/count/time controls.
71
+ * `crossMerchant` is retained as the version-1 persisted field name.
68
72
  */
69
73
  crossMerchant?: boolean;
70
74
  };
@@ -76,6 +80,8 @@ export type CoverQuery = {
76
80
  merchantHost: string;
77
81
  currencyCode: string;
78
82
  amountMinor: number;
83
+ /** Exact selected request key; omitted means legacy unbound mandates only. */
84
+ agentJkt?: string;
79
85
  now?: Date;
80
86
  };
81
87
  /** Available headroom = ceiling - committed - reserved. Integer minor units. */
@@ -93,14 +99,20 @@ export declare class MandateLedger {
93
99
  private run;
94
100
  private load;
95
101
  private save;
96
- /** Append a freshly-created mandate record. */
97
- create(record: CardMandateRecord): Promise<CardMandateRecord>;
102
+ /**
103
+ * Append a freshly-created mandate record. `now` drives the expired-mandate
104
+ * prune below; it defaults to the wall clock but is injectable so callers (and
105
+ * tests) can pin it — every other mutating method takes the same clock, and a
106
+ * real `new Date()` here would otherwise make the prune non-deterministic
107
+ * against fixtures dated relative to a fixed reference time.
108
+ */
109
+ create(record: CardMandateRecord, now?: Date): Promise<CardMandateRecord>;
98
110
  /** Read a single mandate (no lock — a snapshot copy). */
99
111
  get(mandateId: string): Promise<CardMandateRecord | null>;
100
112
  /**
101
113
  * First ACTIVE mandate (not expired) whose merchant + currency match and whose
102
114
  * remaining headroom covers amountMinor. Used by pay_merchant to decide the
103
- * tap-free draw path vs a fresh per-purchase tap.
115
+ * tap-free draw path vs the no-covering-mandate refusal (#7348).
104
116
  */
105
117
  findCovering(query: CoverQuery): Promise<CardMandateRecord | null>;
106
118
  /**
@@ -118,11 +130,39 @@ export declare class MandateLedger {
118
130
  * Throws only if the mandate is unknown.
119
131
  */
120
132
  markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
133
+ /**
134
+ * Retire a locally cached mandate after the authenticated account API proves
135
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
136
+ * never be undone by a later stale/local read.
137
+ */
138
+ markServerRevoked(mandateId: string, revokedAt: string): Promise<CardMandateRecord>;
139
+ /**
140
+ * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
141
+ *
142
+ * The requested ceiling is only a request. At register, auth clamps it to the
143
+ * owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
144
+ * that. The local record used to keep the requested figure, so a runtime whose
145
+ * request exceeded the grant reported — and selected against — a budget the
146
+ * owner never approved. The draw still failed closed at auth's verdict, so it
147
+ * was never over-spend; it was the runtime lying about its own headroom and
148
+ * then hitting a decline it could not explain.
149
+ *
150
+ * LOWERS ONLY. A value at or above the current ceiling is ignored, not
151
+ * written: the server clamps downward, so a higher number means an unexpected
152
+ * response, and honouring it would let a client-observed value hand a runtime
153
+ * headroom no human approved. Cap authority stays server-side either way —
154
+ * this only stops the local copy from overstating it.
155
+ *
156
+ * Atomic and idempotent on the same serialized chain as every other mutation.
157
+ * Throws only if the mandate is unknown.
158
+ */
159
+ applyApprovedCeiling(mandateId: string, approvedCeilingMinor: number): Promise<CardMandateRecord>;
121
160
  /**
122
161
  * Atomically reserve headroom for a draw. Fail-closed: refuses when the
123
- * mandate is unknown, expired, or the amount exceeds remaining headroom. The
124
- * reservation counts against availability immediately, closing the window in
125
- * which two concurrent draws both see the same remaining balance.
162
+ * mandate is unknown, retired, expired, or the amount exceeds remaining
163
+ * headroom. The reservation counts against availability immediately, closing
164
+ * the window in which two concurrent draws both see the same remaining
165
+ * balance.
126
166
  */
127
167
  reserve(mandateId: string, amountMinor: number, now?: Date): Promise<string>;
128
168
  /** Commit a reservation to permanent spend and record the payable draw. */
@@ -11,8 +11,8 @@
11
11
  //
12
12
  // VGS's decline_threshold is a per-transaction NETWORK control; this ledger is
13
13
  // the SEPARATE cumulative budget control. The file holds identifiers and
14
- // accounting state plus the scoped mint token (owner-only) — never PAN, DPAN,
15
- // CVC, or cryptogram values. Cross-process concurrency is NOT solved by a file
14
+ // accounting state only never a bootstrap mint token, PAN, DPAN, CVC, or
15
+ // cryptogram values. Cross-process concurrency is NOT solved by a file
16
16
  // (same caveat csmoove notes); the in-process mutex below serializes draws
17
17
  // within one CLI process, which is the live-spike target.
18
18
  //
@@ -36,7 +36,10 @@ const LEDGER_MAX_BYTES = 512 * 1024;
36
36
  // A tap-free draw's reserve -> commit/release cycle completes in one network
37
37
  // round-trip; a reservation older than this window can only be the debris of a
38
38
  // crash between reserve and commit/release, so load() sweeps it (L1 self-heal).
39
- const STALE_RESERVATION_MS = 5 * 60 * 1000;
39
+ // Must match the server-authoritative TTL (CARD_MANDATE_RESERVATION_TTL_SECONDS
40
+ // in apps/auth/src/server.ts, default 15 min) to avoid a window where the local
41
+ // ledger over-reports available headroom.
42
+ const STALE_RESERVATION_MS = 15 * 60 * 1000;
40
43
  // Mandates expired longer ago than this are pruned on the next create(). Generous
41
44
  // (a week) so a recently-expired mandate is still visible in `mandate list`, but
42
45
  // bounded so a long-lived heavy user cannot grow the owner-only ledger file into
@@ -75,6 +78,13 @@ function isExpired(record, now) {
75
78
  const end = Date.parse(record.expiresAt);
76
79
  return !Number.isFinite(end) || end <= now.getTime();
77
80
  }
81
+ function committedDrawCount(record) {
82
+ return record.draws.filter((draw) => draw.status === 'committed').length;
83
+ }
84
+ function hasDrawCountHeadroom(record) {
85
+ return (record.maxDraws === undefined ||
86
+ committedDrawCount(record) + record.reservations.length < record.maxDraws);
87
+ }
78
88
  function assertPositiveInteger(value, label) {
79
89
  if (!Number.isSafeInteger(value) || value <= 0) {
80
90
  throw new Error(`${label} must be a positive integer (minor units)`);
@@ -111,6 +121,18 @@ export class MandateLedger {
111
121
  if (!doc || !Array.isArray(doc.mandates)) {
112
122
  return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
113
123
  }
124
+ // One-time migration for pre-hardening ledgers. The budget mint is a
125
+ // bootstrap bearer used only during create/register and must not survive
126
+ // process exit. Scrub it eagerly on read, not merely on the next mutation.
127
+ let scrubbedBootstrapToken = false;
128
+ for (const mandate of doc.mandates) {
129
+ if (Object.prototype.hasOwnProperty.call(mandate, 'mintToken')) {
130
+ delete mandate.mintToken;
131
+ scrubbedBootstrapToken = true;
132
+ }
133
+ }
134
+ if (scrubbedBootstrapToken)
135
+ await writeOwnerOnlyJson(this.path, doc);
114
136
  // Self-heal: prune reservations stranded by a crash before commit/release
115
137
  // so `remaining` reflects reality. In-memory here; a mutating caller then
116
138
  // persists the pruned state via save().
@@ -129,8 +151,14 @@ export class MandateLedger {
129
151
  async save(file) {
130
152
  await writeOwnerOnlyJson(this.path, file);
131
153
  }
132
- /** Append a freshly-created mandate record. */
133
- create(record) {
154
+ /**
155
+ * Append a freshly-created mandate record. `now` drives the expired-mandate
156
+ * prune below; it defaults to the wall clock but is injectable so callers (and
157
+ * tests) can pin it — every other mutating method takes the same clock, and a
158
+ * real `new Date()` here would otherwise make the prune non-deterministic
159
+ * against fixtures dated relative to a fixed reference time.
160
+ */
161
+ create(record, now = new Date()) {
134
162
  return this.run(async () => {
135
163
  const file = await this.load();
136
164
  if (file.mandates.some((m) => m.mandateId === record.mandateId)) {
@@ -140,14 +168,17 @@ export class MandateLedger {
140
168
  // are already invisible to `mandate list` and unselectable by findCovering,
141
169
  // so nothing references them. A mandate with an unparseable expiry is kept
142
170
  // (fail-safe — never prune what we cannot date).
143
- const pruneBefore = new Date().getTime() - EXPIRED_PRUNE_GRACE_MS;
171
+ const pruneBefore = now.getTime() - EXPIRED_PRUNE_GRACE_MS;
144
172
  file.mandates = file.mandates.filter((m) => {
145
173
  const exp = Date.parse(m.expiresAt);
146
174
  return !Number.isFinite(exp) || exp >= pruneBefore;
147
175
  });
148
- file.mandates.push(record);
176
+ // Even an older caller that still supplies the deprecated field cannot
177
+ // persist the bootstrap bearer.
178
+ const { mintToken: _discardedBootstrapToken, ...safeRecord } = record;
179
+ file.mandates.push(safeRecord);
149
180
  await this.save(file);
150
- return record;
181
+ return safeRecord;
151
182
  });
152
183
  }
153
184
  /** Read a single mandate (no lock — a snapshot copy). */
@@ -158,7 +189,7 @@ export class MandateLedger {
158
189
  /**
159
190
  * First ACTIVE mandate (not expired) whose merchant + currency match and whose
160
191
  * remaining headroom covers amountMinor. Used by pay_merchant to decide the
161
- * tap-free draw path vs a fresh per-purchase tap.
192
+ * tap-free draw path vs the no-covering-mandate refusal (#7348).
162
193
  */
163
194
  async findCovering(query) {
164
195
  const now = query.now ?? new Date();
@@ -168,25 +199,28 @@ export class MandateLedger {
168
199
  // mandate the network refused is skipped via `!m.unhonoredAt` so it can never
169
200
  // be re-selected. A register-failed mandate is skipped the same way
170
201
  // (`!m.registerFailedAt`): it has no server row, so a delegated draw would 404
171
- // `no_mandate` — better to fall through to a fresh per-purchase tap.
202
+ // `no_mandate` — better to surface the no-covering refusal (#7348).
172
203
  // Selection order among covering candidates:
173
204
  // 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
174
205
  // spend the dedicated grant for this merchant first and keep the broader
175
- // any-merchant budget for merchants that have no scoped mandate. Draining
206
+ // broader retail budget for merchants that have no scoped mandate. Draining
176
207
  // the budget for a purchase a scoped mandate already covers both wastes
177
208
  // the general headroom and can later force a fresh tap at another merchant.
178
209
  // 2. Then the MOST headroom, then the latest expiry — never let a near-empty
179
210
  // or near-expiry mandate get selected over a fuller, longer-lived sibling
180
211
  // and then fail a draw the sibling would have covered.
181
212
  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.
213
+ // A crossMerchant retail budget is locally cross-host; a merchant-scoped
214
+ // one only matches its own host. Network Retail/5999 eligibility is
215
+ // enforced downstream and is not inferred from a website hostname.
185
216
  (m.crossMerchant || m.merchantHost === query.merchantHost) &&
217
+ m.agentJkt === query.agentJkt &&
186
218
  m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
187
219
  !m.unhonoredAt &&
188
220
  !m.registerFailedAt &&
221
+ !m.serverRevokedAt &&
189
222
  !isExpired(m, now) &&
223
+ hasDrawCountHeadroom(m) &&
190
224
  remainingMinor(m) >= query.amountMinor);
191
225
  candidates.sort((a, b) =>
192
226
  // A scoped mandate (crossMerchant falsy → 0) sorts before a budget one (1).
@@ -234,11 +268,67 @@ export class MandateLedger {
234
268
  return record;
235
269
  });
236
270
  }
271
+ /**
272
+ * Retire a locally cached mandate after the authenticated account API proves
273
+ * it was revoked. This is idempotent and monotonic: an owner revocation can
274
+ * never be undone by a later stale/local read.
275
+ */
276
+ markServerRevoked(mandateId, revokedAt) {
277
+ return this.run(async () => {
278
+ const parsed = new Date(revokedAt);
279
+ if (Number.isNaN(parsed.getTime()))
280
+ throw new Error('server revocation timestamp is invalid');
281
+ const file = await this.load();
282
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
283
+ if (!record)
284
+ throw new Error(`no such mandate ${mandateId}`);
285
+ if (!record.serverRevokedAt) {
286
+ record.serverRevokedAt = parsed.toISOString();
287
+ await this.save(file);
288
+ }
289
+ return record;
290
+ });
291
+ }
292
+ /**
293
+ * ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
294
+ *
295
+ * The requested ceiling is only a request. At register, auth clamps it to the
296
+ * owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
297
+ * that. The local record used to keep the requested figure, so a runtime whose
298
+ * request exceeded the grant reported — and selected against — a budget the
299
+ * owner never approved. The draw still failed closed at auth's verdict, so it
300
+ * was never over-spend; it was the runtime lying about its own headroom and
301
+ * then hitting a decline it could not explain.
302
+ *
303
+ * LOWERS ONLY. A value at or above the current ceiling is ignored, not
304
+ * written: the server clamps downward, so a higher number means an unexpected
305
+ * response, and honouring it would let a client-observed value hand a runtime
306
+ * headroom no human approved. Cap authority stays server-side either way —
307
+ * this only stops the local copy from overstating it.
308
+ *
309
+ * Atomic and idempotent on the same serialized chain as every other mutation.
310
+ * Throws only if the mandate is unknown.
311
+ */
312
+ applyApprovedCeiling(mandateId, approvedCeilingMinor) {
313
+ return this.run(async () => {
314
+ assertPositiveInteger(approvedCeilingMinor, 'approved mandate ceiling');
315
+ const file = await this.load();
316
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
317
+ if (!record)
318
+ throw new Error(`no such mandate ${mandateId}`);
319
+ if (approvedCeilingMinor >= record.ceilingMinor)
320
+ return record;
321
+ record.ceilingMinor = approvedCeilingMinor;
322
+ await this.save(file);
323
+ return record;
324
+ });
325
+ }
237
326
  /**
238
327
  * Atomically reserve headroom for a draw. Fail-closed: refuses when the
239
- * mandate is unknown, expired, or the amount exceeds remaining headroom. The
240
- * reservation counts against availability immediately, closing the window in
241
- * which two concurrent draws both see the same remaining balance.
328
+ * mandate is unknown, retired, expired, or the amount exceeds remaining
329
+ * headroom. The reservation counts against availability immediately, closing
330
+ * the window in which two concurrent draws both see the same remaining
331
+ * balance.
242
332
  */
243
333
  reserve(mandateId, amountMinor, now = new Date()) {
244
334
  return this.run(async () => {
@@ -249,12 +339,24 @@ export class MandateLedger {
249
339
  const record = file.mandates.find((m) => m.mandateId === mandateId);
250
340
  if (!record)
251
341
  throw new Error(`no such mandate ${mandateId}`);
342
+ if (record.serverRevokedAt) {
343
+ throw new Error(`mandate ${mandateId} was server-revoked at ${record.serverRevokedAt}`);
344
+ }
345
+ if (record.registerFailedAt) {
346
+ throw new Error(`mandate ${mandateId} registration failed at ${record.registerFailedAt}`);
347
+ }
348
+ if (record.unhonoredAt) {
349
+ throw new Error(`mandate ${mandateId} was unhonored at ${record.unhonoredAt}`);
350
+ }
252
351
  if (isExpired(record, now)) {
253
352
  throw new Error(`mandate ${mandateId} expired at ${record.expiresAt}`);
254
353
  }
255
354
  if (amountMinor > remainingMinor(record)) {
256
355
  throw new Error(`draw ${amountMinor} exceeds remaining budget ${remainingMinor(record)} (minor units)`);
257
356
  }
357
+ if (!hasDrawCountHeadroom(record)) {
358
+ throw new Error(`mandate ${mandateId} reached its approved purchase-count limit`);
359
+ }
258
360
  const reservationId = `rsv_${randomBytes(9).toString('base64url')}`;
259
361
  record.reservations.push({ reservationId, amountMinor, reservedAt: now.toISOString() });
260
362
  await this.save(file);
@@ -268,6 +370,11 @@ export class MandateLedger {
268
370
  const record = file.mandates.find((m) => m.mandateId === mandateId);
269
371
  if (!record)
270
372
  throw new Error(`no such mandate ${mandateId}`);
373
+ // Revocation is authoritative even when it arrives after reserve(). Keep
374
+ // the reservation intact so this refusal cannot be mistaken for a commit.
375
+ if (record.serverRevokedAt) {
376
+ throw new Error(`mandate ${mandateId} was server-revoked at ${record.serverRevokedAt}`);
377
+ }
271
378
  const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
272
379
  if (idx === -1) {
273
380
  throw new Error(`reservation ${reservationId} is not open (already committed or released)`);
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Where this device's checkout receipts live. Owned by its own module so the
3
+ * writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
4
+ * drift onto two paths.
5
+ */
6
+ export declare const RECEIPT_DIR: string;
@@ -0,0 +1,8 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ /**
4
+ * Where this device's checkout receipts live. Owned by its own module so the
5
+ * writer (cli-engine.ts) and the reader (confirmed-merchants.ts) can never
6
+ * drift onto two paths.
7
+ */
8
+ export const RECEIPT_DIR = join(homedir(), '.visa-mcp', 'checkout-receipts');
@@ -1,7 +1,8 @@
1
1
  import type { EvidenceStep } from './evidence.js';
2
2
  import type { CheckoutMode, CheckoutOutcome, CheckoutResult, CredentialLifecycle, CredentialTiming } from './executor.js';
3
3
  import type { VicConfirmationReport } from './vic-confirmation.js';
4
- export type CheckoutReceipt = {
4
+ /** Published v1 artifact shape. Retained so patch releases remain readable/typable. */
5
+ export type CheckoutReceiptV1 = {
5
6
  schema: 'checkout-agent-receipt/v1';
6
7
  recordedAt: string;
7
8
  mode: CheckoutMode;
@@ -32,6 +33,55 @@ export type CheckoutReceipt = {
32
33
  snapshotSummary: string | null;
33
34
  };
34
35
  };
36
+ export type CheckoutReceiptV2 = {
37
+ schema: 'checkout-agent-receipt/v2';
38
+ recordedAt: string;
39
+ /** Correlates terminal pre-review observations that have no reviewId yet. */
40
+ observationId?: string;
41
+ /** Explicitly distinguishes pre-submit review observations from payment attempts. */
42
+ phase?: 'review';
43
+ merchant: {
44
+ name: string;
45
+ host: string;
46
+ checkoutUrl: string | null;
47
+ };
48
+ transaction: {
49
+ amount: string;
50
+ amountMinor: number;
51
+ currency: string;
52
+ };
53
+ outcome: CheckoutOutcome;
54
+ agent: {
55
+ name: string;
56
+ } | null;
57
+ rail: {
58
+ type: 'card';
59
+ cardLast4: string | null;
60
+ };
61
+ network: {
62
+ confirmation: 'APPROVED' | 'DECLINED' | null;
63
+ };
64
+ /**
65
+ * Capability evidence for unsuccessful checkout surfaces. Optional so
66
+ * previously published v2 receipt producers remain source-compatible.
67
+ */
68
+ capability?: {
69
+ failureCode: string | null;
70
+ detectedRoles: string[];
71
+ requiresAdapter: string[];
72
+ };
73
+ /** Local reviewed-attempt identifier. Keeps filenames stable across schema versions. */
74
+ receiptId: string | null;
75
+ /** Merchant confirmation reference only; never substituted with an internal review id. */
76
+ reference: string | null;
77
+ recovery: {
78
+ required: boolean;
79
+ retrySafe: boolean;
80
+ action: string;
81
+ actions: string[];
82
+ };
83
+ };
84
+ export type CheckoutReceipt = CheckoutReceiptV1 | CheckoutReceiptV2;
35
85
  export type ReceiptWriteReport = {
36
86
  written: true;
37
87
  path: string;
@@ -43,9 +93,11 @@ export type ReceiptWriteReport = {
43
93
  export declare function buildReceipt(input: {
44
94
  mode: CheckoutMode;
45
95
  reviewId: string | null;
96
+ observationId?: string;
46
97
  merchant: {
47
98
  name: string;
48
99
  host: string;
100
+ url?: string;
49
101
  };
50
102
  transaction: {
51
103
  amount: string;
@@ -54,9 +106,11 @@ export declare function buildReceipt(input: {
54
106
  };
55
107
  result: CheckoutResult;
56
108
  vicConfirmation: VicConfirmationReport | null;
109
+ agentName?: string;
110
+ cardLast4?: string;
57
111
  /** Injectable for tests; defaults to now. */
58
112
  recordedAt?: Date;
59
- }): CheckoutReceipt;
113
+ }): CheckoutReceiptV2;
60
114
  /**
61
115
  * Defense-in-depth scrub before anything touches disk: any standalone
62
116
  * 12–19-digit run that passes Luhn is replaced — contiguous OR separated by