@visa/cli 4.1.0-rc.297 → 4.1.0-rc.299

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 (77) hide show
  1. package/README.md +29 -45
  2. package/dist/cli.js +556 -786
  3. package/dist/mcp-server/index.js +408 -622
  4. package/dist/merchant-ucp-mcp/index.js +6 -6
  5. package/dist/skills/pair-visa-agent/SKILL.md +175 -240
  6. package/native/bin/win32-arm64/visa-runtime-signer.exe +0 -0
  7. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  8. package/native/bin/win32-x64/visa-runtime-signer.exe +0 -0
  9. package/package.json +2 -4
  10. package/server.json +2 -2
  11. package/dist/checkout-engine/adapters/generic.d.ts +0 -88
  12. package/dist/checkout-engine/adapters/generic.js +0 -526
  13. package/dist/checkout-engine/adapters/index.d.ts +0 -10
  14. package/dist/checkout-engine/adapters/index.js +0 -24
  15. package/dist/checkout-engine/adapters/shopify.d.ts +0 -98
  16. package/dist/checkout-engine/adapters/shopify.js +0 -744
  17. package/dist/checkout-engine/adapters/stripe-like.d.ts +0 -10
  18. package/dist/checkout-engine/adapters/stripe-like.js +0 -21
  19. package/dist/checkout-engine/amount.d.ts +0 -17
  20. package/dist/checkout-engine/amount.js +0 -72
  21. package/dist/checkout-engine/browser-launch.d.ts +0 -51
  22. package/dist/checkout-engine/browser-launch.js +0 -96
  23. package/dist/checkout-engine/browserbase-browser.d.ts +0 -24
  24. package/dist/checkout-engine/browserbase-browser.js +0 -186
  25. package/dist/checkout-engine/ceremony.d.ts +0 -64
  26. package/dist/checkout-engine/ceremony.js +0 -261
  27. package/dist/checkout-engine/cli-engine.d.ts +0 -417
  28. package/dist/checkout-engine/cli-engine.js +0 -1331
  29. package/dist/checkout-engine/confirmed-merchants.d.ts +0 -31
  30. package/dist/checkout-engine/confirmed-merchants.js +0 -165
  31. package/dist/checkout-engine/detect.d.ts +0 -61
  32. package/dist/checkout-engine/detect.js +0 -398
  33. package/dist/checkout-engine/evidence.d.ts +0 -25
  34. package/dist/checkout-engine/evidence.js +0 -104
  35. package/dist/checkout-engine/executor.d.ts +0 -262
  36. package/dist/checkout-engine/executor.js +0 -1837
  37. package/dist/checkout-engine/hosted-approval.d.ts +0 -195
  38. package/dist/checkout-engine/hosted-approval.js +0 -501
  39. package/dist/checkout-engine/index.d.ts +0 -12
  40. package/dist/checkout-engine/index.js +0 -13
  41. package/dist/checkout-engine/instrument.d.ts +0 -61
  42. package/dist/checkout-engine/instrument.js +0 -87
  43. package/dist/checkout-engine/known-merchants.d.ts +0 -10
  44. package/dist/checkout-engine/known-merchants.js +0 -38
  45. package/dist/checkout-engine/live-fill-approval.d.ts +0 -37
  46. package/dist/checkout-engine/live-fill-approval.js +0 -76
  47. package/dist/checkout-engine/mandate/card-mandate.d.ts +0 -121
  48. package/dist/checkout-engine/mandate/card-mandate.js +0 -226
  49. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +0 -175
  50. package/dist/checkout-engine/mandate/mandate-ledger.js +0 -425
  51. package/dist/checkout-engine/mandate.d.ts +0 -33
  52. package/dist/checkout-engine/mandate.js +0 -135
  53. package/dist/checkout-engine/outcome.d.ts +0 -30
  54. package/dist/checkout-engine/outcome.js +0 -225
  55. package/dist/checkout-engine/owner-only-file.d.ts +0 -19
  56. package/dist/checkout-engine/owner-only-file.js +0 -41
  57. package/dist/checkout-engine/package.json +0 -3
  58. package/dist/checkout-engine/receipt-dir.d.ts +0 -6
  59. package/dist/checkout-engine/receipt-dir.js +0 -8
  60. package/dist/checkout-engine/receipt.d.ts +0 -135
  61. package/dist/checkout-engine/receipt.js +0 -148
  62. package/dist/checkout-engine/shopify-primary-domain.d.ts +0 -25
  63. package/dist/checkout-engine/shopify-primary-domain.js +0 -96
  64. package/dist/checkout-engine/trace-handles.d.ts +0 -8
  65. package/dist/checkout-engine/trace-handles.js +0 -12
  66. package/dist/checkout-engine/types.d.ts +0 -52
  67. package/dist/checkout-engine/types.js +0 -2
  68. package/dist/checkout-engine/unresolved-charges.d.ts +0 -34
  69. package/dist/checkout-engine/unresolved-charges.js +0 -134
  70. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +0 -155
  71. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +0 -493
  72. package/dist/checkout-engine/vgs-live-instrument.d.ts +0 -144
  73. package/dist/checkout-engine/vgs-live-instrument.js +0 -229
  74. package/dist/checkout-engine/vic-confirmation.d.ts +0 -52
  75. package/dist/checkout-engine/vic-confirmation.js +0 -45
  76. package/dist/checkout-engine/web-bot-auth.d.ts +0 -98
  77. package/dist/checkout-engine/web-bot-auth.js +0 -218
@@ -1,175 +0,0 @@
1
- export declare const CARD_MANDATE_LEDGER_VERSION: 1;
2
- export type CardMandateReservation = {
3
- reservationId: string;
4
- amountMinor: number;
5
- reservedAt: string;
6
- };
7
- export type CardMandateDraw = {
8
- drawId: string;
9
- reservationId: string;
10
- amountMinor: number;
11
- intentId: string;
12
- status: 'committed' | 'released';
13
- at: string;
14
- };
15
- export type CardMandateRecord = {
16
- version: typeof CARD_MANDATE_LEDGER_VERSION;
17
- /** == the VGS intentId the ceiling approval created. */
18
- mandateId: string;
19
- /** Exact runtime request key this budget was issued to. Absent on legacy rows. */
20
- agentJkt?: string;
21
- tokenId: string;
22
- ceilingMinor: number;
23
- /** Permanently committed (drawn) spend, integer minor units. */
24
- spentMinor: number;
25
- /** In-flight reservations (reserved but not yet committed/released). */
26
- reservations: CardMandateReservation[];
27
- currencyCode: string;
28
- merchantName: string;
29
- merchantUrl: string;
30
- merchantHost: string;
31
- merchantCountryCode: string;
32
- /** verify-web origin the ceiling intent was minted against. */
33
- approvalBaseUrl: string;
34
- /** @deprecated Read-migration only. New writers never persist this bearer. */
35
- mintToken?: string;
36
- expiresAt: string;
37
- /** Network purchase-count cap disclosed at approval. Absent on legacy rows. */
38
- maxDraws?: number;
39
- createdAt: string;
40
- draws: CardMandateDraw[];
41
- /**
42
- * Set when the network DECLINED a tap-free draw against this mandate (a
43
- * ceiling-scoped assurance the acquirer would not honor for a sub-amount
44
- * draw). Distinct from a reservation `status`: it disables the whole mandate.
45
- * Once set, findCovering() SKIPS this mandate so the caller's next
46
- * pay_merchant surfaces the no-covering-mandate refusal (#7348) instead of
47
- * re-selecting the same failing mandate forever. ISO 8601.
48
- */
49
- unhonoredAt?: string;
50
- /**
51
- * Set when the delegated-draw register handshake FAILED at mandate-start (the
52
- * server-side `card_mandate_spend` row was never created), so a later
53
- * delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
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
- */
59
- registerFailedAt?: string;
60
- /**
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.
72
- */
73
- crossMerchant?: boolean;
74
- };
75
- export type CardMandateLedgerFile = {
76
- version: typeof CARD_MANDATE_LEDGER_VERSION;
77
- mandates: CardMandateRecord[];
78
- };
79
- export type CoverQuery = {
80
- merchantHost: string;
81
- currencyCode: string;
82
- amountMinor: number;
83
- /** Exact selected request key; omitted means legacy unbound mandates only. */
84
- agentJkt?: string;
85
- now?: Date;
86
- };
87
- /** Available headroom = ceiling - committed - reserved. Integer minor units. */
88
- export declare function remainingMinor(record: CardMandateRecord): number;
89
- /**
90
- * The persisted card-mandate ledger. All mutating operations are serialized on
91
- * an in-process promise chain so two concurrent draws cannot both observe the
92
- * same remaining balance (the read-modify-write is atomic within the process).
93
- */
94
- export declare class MandateLedger {
95
- private readonly path;
96
- private chain;
97
- constructor(path?: string);
98
- /** Serialize a read-modify-write so concurrent draws can't race the file. */
99
- private run;
100
- private load;
101
- private save;
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>;
110
- /** Read a single mandate (no lock — a snapshot copy). */
111
- get(mandateId: string): Promise<CardMandateRecord | null>;
112
- /**
113
- * First ACTIVE mandate (not expired) whose merchant + currency match and whose
114
- * remaining headroom covers amountMinor. Used by pay_merchant to decide the
115
- * tap-free draw path vs the no-covering-mandate refusal (#7348).
116
- */
117
- findCovering(query: CoverQuery): Promise<CardMandateRecord | null>;
118
- /**
119
- * Mark a mandate as unhonored — the network declined a ceiling-scoped draw
120
- * against it, so it must never be selected again. Atomic, owner-only write on
121
- * the same serialized chain as every other mutation. Idempotent: a second
122
- * call keeps the first timestamp. Throws only if the mandate is unknown.
123
- */
124
- markUnhonored(mandateId: string, now?: Date): Promise<CardMandateRecord>;
125
- /**
126
- * Mark a mandate as register-failed — the delegated-draw register handshake
127
- * did not create the server row at mandate-start, so it must never be selected
128
- * for a (delegated) draw. Atomic, owner-only write on the same serialized chain
129
- * as every other mutation. Idempotent: a second call keeps the first timestamp.
130
- * Throws only if the mandate is unknown.
131
- */
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>;
160
- /**
161
- * Atomically reserve headroom for a draw. Fail-closed: refuses when the
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.
166
- */
167
- reserve(mandateId: string, amountMinor: number, now?: Date): Promise<string>;
168
- /** Commit a reservation to permanent spend and record the payable draw. */
169
- commit(mandateId: string, reservationId: string, meta: {
170
- intentId: string;
171
- }, now?: Date): Promise<CardMandateRecord>;
172
- /** Release a reservation back to availability (draw failed / not payable). */
173
- release(mandateId: string, reservationId: string, now?: Date): Promise<CardMandateRecord>;
174
- }
175
- export declare function defaultLedgerPath(): string;
@@ -1,425 +0,0 @@
1
- // Owner-only persisted ledger for card mandates (budgets). One passkey approval
2
- // creates one VGS intent with a CEILING decline threshold; this ledger tracks
3
- // the cumulative budget the agent may draw against that intent without a fresh
4
- // tap. It deliberately MIRRORS csmoove530's standing-mandate ledger (#5600 /
5
- // #5724) so the two reconcile later rather than competing:
6
- //
7
- // - owner-only (chmod 600) JSON artifact under ~/.visa-mcp/, atomic write;
8
- // - integer minor-unit accounting only — no floating-point money;
9
- // - atomic reserve -> commit | release around every draw, so approved plus
10
- // in-flight spend can never exceed the authenticated ceiling.
11
- //
12
- // VGS's decline_threshold is a per-transaction NETWORK control; this ledger is
13
- // the SEPARATE cumulative budget control. The file holds identifiers and
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
- // (same caveat csmoove notes); the in-process mutex below serializes draws
17
- // within one CLI process, which is the live-spike target.
18
- //
19
- // SELF-HEALING: a reservation is persisted the instant it is taken and is only
20
- // removed by commit() or release(). A crash BETWEEN reserve and commit/release
21
- // would otherwise strand the reservation forever, permanently shrinking
22
- // `remaining`. The headroom-deciding paths (reserve() and findCovering()) run a
23
- // stale-reservation sweep on load: any reservation older than
24
- // STALE_RESERVATION_MS (a draw cannot legitimately stay in-flight that long) is
25
- // dropped, returning its headroom, so a stranded reservation can never block a
26
- // legitimate draw. reserve()'s save() persists the prune; findCovering() heals
27
- // its in-memory view. The sweep uses the caller's clock (michaelyang1 L1).
28
- import { randomBytes } from 'node:crypto';
29
- import { homedir } from 'node:os';
30
- import { join } from 'node:path';
31
- import { readOwnerOnlyJson, writeOwnerOnlyJson } from '../owner-only-file.js';
32
- export const CARD_MANDATE_LEDGER_VERSION = 1;
33
- // A ledger with a long draw history is larger than a single credential; cap
34
- // generously but still bounded (owner-only-file's default 16 KB is too small).
35
- const LEDGER_MAX_BYTES = 512 * 1024;
36
- // A tap-free draw's reserve -> commit/release cycle completes in one network
37
- // round-trip; a reservation older than this window can only be the debris of a
38
- // crash between reserve and commit/release, so load() sweeps it (L1 self-heal).
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;
43
- // Mandates expired longer ago than this are pruned on the next create(). Generous
44
- // (a week) so a recently-expired mandate is still visible in `mandate list`, but
45
- // bounded so a long-lived heavy user cannot grow the owner-only ledger file into
46
- // its size cap — which would otherwise brick EVERY ledger op (create/reserve/
47
- // findCovering/commit/release all load the file first).
48
- const EXPIRED_PRUNE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
49
- function reservedTotal(record) {
50
- return record.reservations.reduce((sum, r) => sum + r.amountMinor, 0);
51
- }
52
- /**
53
- * Drop reservations older than STALE_RESERVATION_MS across every mandate — the
54
- * debris of a crash between reserve and commit/release. Mutates in place and
55
- * returns true if anything was swept (so the caller can decide to persist).
56
- * A reservation with an unparseable `reservedAt` is treated as stale.
57
- */
58
- function sweepStaleReservations(file, nowMs) {
59
- let swept = false;
60
- for (const m of file.mandates) {
61
- const kept = m.reservations.filter((r) => {
62
- const reservedMs = Date.parse(r.reservedAt);
63
- const stale = !Number.isFinite(reservedMs) || nowMs - reservedMs > STALE_RESERVATION_MS;
64
- if (stale)
65
- swept = true;
66
- return !stale;
67
- });
68
- if (kept.length !== m.reservations.length)
69
- m.reservations = kept;
70
- }
71
- return swept;
72
- }
73
- /** Available headroom = ceiling - committed - reserved. Integer minor units. */
74
- export function remainingMinor(record) {
75
- return record.ceilingMinor - record.spentMinor - reservedTotal(record);
76
- }
77
- function isExpired(record, now) {
78
- const end = Date.parse(record.expiresAt);
79
- return !Number.isFinite(end) || end <= now.getTime();
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
- }
88
- function assertPositiveInteger(value, label) {
89
- if (!Number.isSafeInteger(value) || value <= 0) {
90
- throw new Error(`${label} must be a positive integer (minor units)`);
91
- }
92
- }
93
- /**
94
- * The persisted card-mandate ledger. All mutating operations are serialized on
95
- * an in-process promise chain so two concurrent draws cannot both observe the
96
- * same remaining balance (the read-modify-write is atomic within the process).
97
- */
98
- export class MandateLedger {
99
- path;
100
- chain = Promise.resolve();
101
- constructor(path = defaultLedgerPath()) {
102
- this.path = path;
103
- }
104
- /** Serialize a read-modify-write so concurrent draws can't race the file. */
105
- run(fn) {
106
- const next = this.chain.then(fn, fn);
107
- // Keep the chain alive even if this op rejects; swallow only the chain copy.
108
- this.chain = next.then(() => undefined, () => undefined);
109
- return next;
110
- }
111
- // `nowMs` is the caller's clock for the stale-reservation sweep. The sweep
112
- // deliberately runs only on the headroom-DECIDING loads — reserve() and
113
- // findCovering() — which are the paths that must not be blocked by
114
- // crash-stranded reservation debris. commit(), release(), and markUnhonored()
115
- // pass NO clock on purpose: sweeping while finalizing a reservation could drop
116
- // the very reservation being committed/released. Pure snapshot reads (get) and
117
- // create() are likewise clock-independent.
118
- async load(nowMs) {
119
- try {
120
- const doc = await readOwnerOnlyJson(this.path, 'card mandate ledger', LEDGER_MAX_BYTES);
121
- if (!doc || !Array.isArray(doc.mandates)) {
122
- return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
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);
136
- // Self-heal: prune reservations stranded by a crash before commit/release
137
- // so `remaining` reflects reality. In-memory here; a mutating caller then
138
- // persists the pruned state via save().
139
- if (nowMs !== undefined)
140
- sweepStaleReservations(doc, nowMs);
141
- return doc;
142
- }
143
- catch (err) {
144
- // A missing ledger is the normal first-run state.
145
- if (err.code === 'ENOENT') {
146
- return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
147
- }
148
- throw err;
149
- }
150
- }
151
- async save(file) {
152
- await writeOwnerOnlyJson(this.path, file);
153
- }
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()) {
162
- return this.run(async () => {
163
- const file = await this.load();
164
- if (file.mandates.some((m) => m.mandateId === record.mandateId)) {
165
- throw new Error(`mandate ${record.mandateId} already exists in the ledger`);
166
- }
167
- // Bound ledger growth: drop mandates expired beyond the grace window. They
168
- // are already invisible to `mandate list` and unselectable by findCovering,
169
- // so nothing references them. A mandate with an unparseable expiry is kept
170
- // (fail-safe — never prune what we cannot date).
171
- const pruneBefore = now.getTime() - EXPIRED_PRUNE_GRACE_MS;
172
- file.mandates = file.mandates.filter((m) => {
173
- const exp = Date.parse(m.expiresAt);
174
- return !Number.isFinite(exp) || exp >= pruneBefore;
175
- });
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);
180
- await this.save(file);
181
- return safeRecord;
182
- });
183
- }
184
- /** Read a single mandate (no lock — a snapshot copy). */
185
- async get(mandateId) {
186
- const file = await this.load();
187
- return file.mandates.find((m) => m.mandateId === mandateId) ?? null;
188
- }
189
- /**
190
- * First ACTIVE mandate (not expired) whose merchant + currency match and whose
191
- * remaining headroom covers amountMinor. Used by pay_merchant to decide the
192
- * tap-free draw path vs the no-covering-mandate refusal (#7348).
193
- */
194
- async findCovering(query) {
195
- const now = query.now ?? new Date();
196
- const file = await this.load(now.getTime());
197
- // Multiple mandates can now cover one merchant: a merchant-scoped mandate for
198
- // this host AND any crossMerchant (budget) mandate both qualify (#6003). A
199
- // mandate the network refused is skipped via `!m.unhonoredAt` so it can never
200
- // be re-selected. A register-failed mandate is skipped the same way
201
- // (`!m.registerFailedAt`): it has no server row, so a delegated draw would 404
202
- // `no_mandate` — better to surface the no-covering refusal (#7348).
203
- // Selection order among covering candidates:
204
- // 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
205
- // spend the dedicated grant for this merchant first and keep the broader
206
- // broader retail budget for merchants that have no scoped mandate. Draining
207
- // the budget for a purchase a scoped mandate already covers both wastes
208
- // the general headroom and can later force a fresh tap at another merchant.
209
- // 2. Then the MOST headroom, then the latest expiry — never let a near-empty
210
- // or near-expiry mandate get selected over a fuller, longer-lived sibling
211
- // and then fail a draw the sibling would have covered.
212
- const candidates = file.mandates.filter((m) =>
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.
216
- (m.crossMerchant || m.merchantHost === query.merchantHost) &&
217
- m.agentJkt === query.agentJkt &&
218
- m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
219
- !m.unhonoredAt &&
220
- !m.registerFailedAt &&
221
- !m.serverRevokedAt &&
222
- !isExpired(m, now) &&
223
- hasDrawCountHeadroom(m) &&
224
- remainingMinor(m) >= query.amountMinor);
225
- candidates.sort((a, b) =>
226
- // A scoped mandate (crossMerchant falsy → 0) sorts before a budget one (1).
227
- (a.crossMerchant ? 1 : 0) - (b.crossMerchant ? 1 : 0) ||
228
- remainingMinor(b) - remainingMinor(a) ||
229
- Date.parse(b.expiresAt) - Date.parse(a.expiresAt));
230
- return candidates[0] ?? null;
231
- }
232
- /**
233
- * Mark a mandate as unhonored — the network declined a ceiling-scoped draw
234
- * against it, so it must never be selected again. Atomic, owner-only write on
235
- * the same serialized chain as every other mutation. Idempotent: a second
236
- * call keeps the first timestamp. Throws only if the mandate is unknown.
237
- */
238
- markUnhonored(mandateId, now = new Date()) {
239
- return this.run(async () => {
240
- const file = await this.load();
241
- const record = file.mandates.find((m) => m.mandateId === mandateId);
242
- if (!record)
243
- throw new Error(`no such mandate ${mandateId}`);
244
- if (!record.unhonoredAt) {
245
- record.unhonoredAt = now.toISOString();
246
- await this.save(file);
247
- }
248
- return record;
249
- });
250
- }
251
- /**
252
- * Mark a mandate as register-failed — the delegated-draw register handshake
253
- * did not create the server row at mandate-start, so it must never be selected
254
- * for a (delegated) draw. Atomic, owner-only write on the same serialized chain
255
- * as every other mutation. Idempotent: a second call keeps the first timestamp.
256
- * Throws only if the mandate is unknown.
257
- */
258
- markRegisterFailed(mandateId, now = new Date()) {
259
- return this.run(async () => {
260
- const file = await this.load();
261
- const record = file.mandates.find((m) => m.mandateId === mandateId);
262
- if (!record)
263
- throw new Error(`no such mandate ${mandateId}`);
264
- if (!record.registerFailedAt) {
265
- record.registerFailedAt = now.toISOString();
266
- await this.save(file);
267
- }
268
- return record;
269
- });
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
- }
326
- /**
327
- * Atomically reserve headroom for a draw. Fail-closed: refuses when the
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.
332
- */
333
- reserve(mandateId, amountMinor, now = new Date()) {
334
- return this.run(async () => {
335
- assertPositiveInteger(amountMinor, 'draw amount');
336
- // Sweep with the draw's clock so a crash-stranded reservation cannot block
337
- // a legitimate draw; the save() below persists the pruned state.
338
- const file = await this.load(now.getTime());
339
- const record = file.mandates.find((m) => m.mandateId === mandateId);
340
- if (!record)
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
- }
351
- if (isExpired(record, now)) {
352
- throw new Error(`mandate ${mandateId} expired at ${record.expiresAt}`);
353
- }
354
- if (amountMinor > remainingMinor(record)) {
355
- throw new Error(`draw ${amountMinor} exceeds remaining budget ${remainingMinor(record)} (minor units)`);
356
- }
357
- if (!hasDrawCountHeadroom(record)) {
358
- throw new Error(`mandate ${mandateId} reached its approved purchase-count limit`);
359
- }
360
- const reservationId = `rsv_${randomBytes(9).toString('base64url')}`;
361
- record.reservations.push({ reservationId, amountMinor, reservedAt: now.toISOString() });
362
- await this.save(file);
363
- return reservationId;
364
- });
365
- }
366
- /** Commit a reservation to permanent spend and record the payable draw. */
367
- commit(mandateId, reservationId, meta, now = new Date()) {
368
- return this.run(async () => {
369
- const file = await this.load();
370
- const record = file.mandates.find((m) => m.mandateId === mandateId);
371
- if (!record)
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
- }
378
- const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
379
- if (idx === -1) {
380
- throw new Error(`reservation ${reservationId} is not open (already committed or released)`);
381
- }
382
- const [reservation] = record.reservations.splice(idx, 1);
383
- record.spentMinor += reservation.amountMinor;
384
- record.draws.push({
385
- drawId: `drw_${randomBytes(9).toString('base64url')}`,
386
- reservationId,
387
- amountMinor: reservation.amountMinor,
388
- intentId: meta.intentId,
389
- status: 'committed',
390
- at: now.toISOString(),
391
- });
392
- await this.save(file);
393
- return record;
394
- });
395
- }
396
- /** Release a reservation back to availability (draw failed / not payable). */
397
- release(mandateId, reservationId, now = new Date()) {
398
- return this.run(async () => {
399
- const file = await this.load();
400
- const record = file.mandates.find((m) => m.mandateId === mandateId);
401
- if (!record)
402
- throw new Error(`no such mandate ${mandateId}`);
403
- const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
404
- if (idx === -1) {
405
- // Idempotent by refusal: a committed/released reservation cannot be
406
- // released again, but a redundant release must not corrupt state.
407
- return record;
408
- }
409
- const [reservation] = record.reservations.splice(idx, 1);
410
- record.draws.push({
411
- drawId: `drw_${randomBytes(9).toString('base64url')}`,
412
- reservationId,
413
- amountMinor: reservation.amountMinor,
414
- intentId: record.mandateId,
415
- status: 'released',
416
- at: now.toISOString(),
417
- });
418
- await this.save(file);
419
- return record;
420
- });
421
- }
422
- }
423
- export function defaultLedgerPath() {
424
- return (process.env.VISA_CARD_MANDATE_LEDGER_FILE ?? join(homedir(), '.visa-mcp', 'card-mandates.json'));
425
- }
@@ -1,33 +0,0 @@
1
- export type Mandate = {
2
- maxAmountMinor: number;
3
- currency: string;
4
- merchantHost?: string;
5
- expiresAt: string;
6
- };
7
- export type MandateContext = {
8
- merchantHost: string;
9
- amountMinor: number;
10
- currency: string;
11
- now?: Date;
12
- };
13
- export type MandatePreFillContext = {
14
- merchantHost: string;
15
- currency?: string | null;
16
- now?: Date;
17
- };
18
- /**
19
- * Bounded, machine-readable reason a review or pay was refused by the mandate
20
- * or trusted-identity gate. Callers branch on this instead of parsing the
21
- * human-readable reason sentence.
22
- */
23
- export type MandateRefusalCode = 'mandate_missing' | 'mandate_malformed' | 'mandate_expired' | 'currency_mismatch' | 'merchant_host_mismatch' | 'currency_unreadable' | 'amount_invalid' | 'amount_over_cap' | 'amount_unreadable' | 'trusted_handoff_expired' | 'trusted_origin_insecure' | 'trusted_origin_changed' | 'trusted_origin_undeclared' | 'review_facts_changed';
24
- export declare const MANDATE_REFUSAL_CODES: ReadonlySet<MandateRefusalCode>;
25
- export type MandateVerdict = {
26
- ok: true;
27
- } | {
28
- ok: false;
29
- reason: string;
30
- code: MandateRefusalCode;
31
- };
32
- export declare function checkMandatePreFill(mandate: Mandate | null | undefined, ctx: MandatePreFillContext): MandateVerdict;
33
- export declare function checkMandate(mandate: Mandate | null | undefined, ctx: MandateContext): MandateVerdict;