@visa/cli 4.1.0-rc.6 → 4.1.0-rc.61
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.
- package/README.md +168 -235
- package/dist/checkout-engine/adapters/generic.d.ts +23 -0
- package/dist/checkout-engine/adapters/generic.js +216 -0
- package/dist/checkout-engine/adapters/index.d.ts +8 -0
- package/dist/checkout-engine/adapters/index.js +21 -0
- package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
- package/dist/checkout-engine/adapters/shopify.js +423 -0
- package/dist/checkout-engine/adapters/stripe-like.d.ts +10 -0
- package/dist/checkout-engine/adapters/stripe-like.js +21 -0
- package/dist/checkout-engine/amount.d.ts +15 -0
- package/dist/checkout-engine/amount.js +72 -0
- package/dist/checkout-engine/browser-launch.d.ts +46 -0
- package/dist/checkout-engine/browser-launch.js +81 -0
- package/dist/checkout-engine/ceremony.d.ts +64 -0
- package/dist/checkout-engine/ceremony.js +261 -0
- package/dist/checkout-engine/cli-engine.d.ts +200 -0
- package/dist/checkout-engine/cli-engine.js +659 -0
- package/dist/checkout-engine/detect.d.ts +61 -0
- package/dist/checkout-engine/detect.js +398 -0
- package/dist/checkout-engine/evidence.d.ts +25 -0
- package/dist/checkout-engine/evidence.js +104 -0
- package/dist/checkout-engine/executor.d.ts +176 -0
- package/dist/checkout-engine/executor.js +1322 -0
- package/dist/checkout-engine/hosted-approval.d.ts +142 -0
- package/dist/checkout-engine/hosted-approval.js +339 -0
- package/dist/checkout-engine/index.d.ts +6 -0
- package/dist/checkout-engine/index.js +8 -0
- package/dist/checkout-engine/inline-target.d.ts +13 -0
- package/dist/checkout-engine/inline-target.js +37 -0
- package/dist/checkout-engine/instrument.d.ts +61 -0
- package/dist/checkout-engine/instrument.js +87 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +43 -0
- package/dist/checkout-engine/live-fill-approval.js +90 -0
- package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
- package/dist/checkout-engine/mandate/card-mandate.js +227 -0
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +142 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +335 -0
- package/dist/checkout-engine/mandate.d.ts +25 -0
- package/dist/checkout-engine/mandate.js +100 -0
- package/dist/checkout-engine/outcome.d.ts +30 -0
- package/dist/checkout-engine/outcome.js +225 -0
- package/dist/checkout-engine/owner-only-file.d.ts +19 -0
- package/dist/checkout-engine/owner-only-file.js +41 -0
- package/dist/checkout-engine/package.json +3 -0
- package/dist/checkout-engine/receipt.d.ts +81 -0
- package/dist/checkout-engine/receipt.js +109 -0
- package/dist/checkout-engine/repo-env.d.ts +11 -0
- package/dist/checkout-engine/repo-env.js +23 -0
- package/dist/checkout-engine/trace-handles.d.ts +8 -0
- package/dist/checkout-engine/trace-handles.js +12 -0
- package/dist/checkout-engine/types.d.ts +44 -0
- package/dist/checkout-engine/types.js +2 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +74 -0
- package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +248 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
- package/dist/checkout-engine/vgs-live-instrument.d.ts +170 -0
- package/dist/checkout-engine/vgs-live-instrument.js +293 -0
- package/dist/checkout-engine/vic-confirmation.d.ts +34 -0
- package/dist/checkout-engine/vic-confirmation.js +39 -0
- package/dist/cli.js +391 -427
- package/dist/mcp-server/index.js +312 -163
- package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
- package/dist/skills/pair-visa-agent/SKILL.md +315 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
- package/install.ps1 +3 -41
- package/install.sh +3 -35
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +11 -7
- package/server.json +3 -3
|
@@ -0,0 +1,142 @@
|
|
|
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
|
+
/**
|
|
35
|
+
* Scoped token released by the ceiling approval. It bootstraps the one intent
|
|
36
|
+
* + register ceremony only. Each cryptogram AND its outcome confirmation use
|
|
37
|
+
* the exact per-draw verdict instead. Owner-only (0600).
|
|
38
|
+
*/
|
|
39
|
+
mintToken?: string;
|
|
40
|
+
expiresAt: string;
|
|
41
|
+
/** Network purchase-count cap disclosed at approval. Absent on legacy rows. */
|
|
42
|
+
maxDraws?: number;
|
|
43
|
+
createdAt: string;
|
|
44
|
+
draws: CardMandateDraw[];
|
|
45
|
+
/**
|
|
46
|
+
* Set when the network DECLINED a tap-free draw against this mandate (a
|
|
47
|
+
* ceiling-scoped assurance the acquirer would not honor for a sub-amount
|
|
48
|
+
* draw). Distinct from a reservation `status`: it disables the whole mandate.
|
|
49
|
+
* Once set, findCovering() SKIPS this mandate so the caller's next
|
|
50
|
+
* pay_merchant falls through to a fresh per-purchase tap instead of
|
|
51
|
+
* re-selecting the same failing mandate forever. ISO 8601.
|
|
52
|
+
*/
|
|
53
|
+
unhonoredAt?: string;
|
|
54
|
+
/**
|
|
55
|
+
* Set when the delegated-draw register handshake FAILED at mandate-start (the
|
|
56
|
+
* server-side `card_mandate_spend` row was never created), so a later
|
|
57
|
+
* delegated (verdict-signed) draw would 404 `no_mandate`. Like `unhonoredAt`,
|
|
58
|
+
* findCovering() SKIPS a register-failed mandate so the next checkout falls
|
|
59
|
+
* through to a fresh per-purchase tap instead of surfacing a confusing
|
|
60
|
+
* `no_mandate`. Mandate-start requires delegated card authority, so every
|
|
61
|
+
* created record attempted registration. ISO 8601.
|
|
62
|
+
*/
|
|
63
|
+
registerFailedAt?: string;
|
|
64
|
+
/**
|
|
65
|
+
* A cross-host retail budget, not scoped to one `merchantHost`. The local
|
|
66
|
+
* selector may attempt it at any host, while the provider/network still
|
|
67
|
+
* applies the approved Retail/5999 category plus amount/count/time controls.
|
|
68
|
+
* `crossMerchant` is retained as the version-1 persisted field name.
|
|
69
|
+
*/
|
|
70
|
+
crossMerchant?: boolean;
|
|
71
|
+
};
|
|
72
|
+
export type CardMandateLedgerFile = {
|
|
73
|
+
version: typeof CARD_MANDATE_LEDGER_VERSION;
|
|
74
|
+
mandates: CardMandateRecord[];
|
|
75
|
+
};
|
|
76
|
+
export type CoverQuery = {
|
|
77
|
+
merchantHost: string;
|
|
78
|
+
currencyCode: string;
|
|
79
|
+
amountMinor: number;
|
|
80
|
+
now?: Date;
|
|
81
|
+
};
|
|
82
|
+
/** Available headroom = ceiling - committed - reserved. Integer minor units. */
|
|
83
|
+
export declare function remainingMinor(record: CardMandateRecord): number;
|
|
84
|
+
/**
|
|
85
|
+
* The persisted card-mandate ledger. All mutating operations are serialized on
|
|
86
|
+
* an in-process promise chain so two concurrent draws cannot both observe the
|
|
87
|
+
* same remaining balance (the read-modify-write is atomic within the process).
|
|
88
|
+
*/
|
|
89
|
+
export declare class MandateLedger {
|
|
90
|
+
private readonly path;
|
|
91
|
+
private chain;
|
|
92
|
+
constructor(path?: string);
|
|
93
|
+
/** Serialize a read-modify-write so concurrent draws can't race the file. */
|
|
94
|
+
private run;
|
|
95
|
+
private load;
|
|
96
|
+
private save;
|
|
97
|
+
/**
|
|
98
|
+
* Append a freshly-created mandate record. `now` drives the expired-mandate
|
|
99
|
+
* prune below; it defaults to the wall clock but is injectable so callers (and
|
|
100
|
+
* tests) can pin it — every other mutating method takes the same clock, and a
|
|
101
|
+
* real `new Date()` here would otherwise make the prune non-deterministic
|
|
102
|
+
* against fixtures dated relative to a fixed reference time.
|
|
103
|
+
*/
|
|
104
|
+
create(record: CardMandateRecord, now?: Date): Promise<CardMandateRecord>;
|
|
105
|
+
/** Read a single mandate (no lock — a snapshot copy). */
|
|
106
|
+
get(mandateId: string): Promise<CardMandateRecord | null>;
|
|
107
|
+
/**
|
|
108
|
+
* First ACTIVE mandate (not expired) whose merchant + currency match and whose
|
|
109
|
+
* remaining headroom covers amountMinor. Used by pay_merchant to decide the
|
|
110
|
+
* tap-free draw path vs a fresh per-purchase tap.
|
|
111
|
+
*/
|
|
112
|
+
findCovering(query: CoverQuery): Promise<CardMandateRecord | null>;
|
|
113
|
+
/**
|
|
114
|
+
* Mark a mandate as unhonored — the network declined a ceiling-scoped draw
|
|
115
|
+
* against it, so it must never be selected again. Atomic, owner-only write on
|
|
116
|
+
* the same serialized chain as every other mutation. Idempotent: a second
|
|
117
|
+
* call keeps the first timestamp. Throws only if the mandate is unknown.
|
|
118
|
+
*/
|
|
119
|
+
markUnhonored(mandateId: string, now?: Date): Promise<CardMandateRecord>;
|
|
120
|
+
/**
|
|
121
|
+
* Mark a mandate as register-failed — the delegated-draw register handshake
|
|
122
|
+
* did not create the server row at mandate-start, so it must never be selected
|
|
123
|
+
* for a (delegated) draw. Atomic, owner-only write on the same serialized chain
|
|
124
|
+
* as every other mutation. Idempotent: a second call keeps the first timestamp.
|
|
125
|
+
* Throws only if the mandate is unknown.
|
|
126
|
+
*/
|
|
127
|
+
markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
|
|
128
|
+
/**
|
|
129
|
+
* Atomically reserve headroom for a draw. Fail-closed: refuses when the
|
|
130
|
+
* mandate is unknown, expired, or the amount exceeds remaining headroom. The
|
|
131
|
+
* reservation counts against availability immediately, closing the window in
|
|
132
|
+
* which two concurrent draws both see the same remaining balance.
|
|
133
|
+
*/
|
|
134
|
+
reserve(mandateId: string, amountMinor: number, now?: Date): Promise<string>;
|
|
135
|
+
/** Commit a reservation to permanent spend and record the payable draw. */
|
|
136
|
+
commit(mandateId: string, reservationId: string, meta: {
|
|
137
|
+
intentId: string;
|
|
138
|
+
}, now?: Date): Promise<CardMandateRecord>;
|
|
139
|
+
/** Release a reservation back to availability (draw failed / not payable). */
|
|
140
|
+
release(mandateId: string, reservationId: string, now?: Date): Promise<CardMandateRecord>;
|
|
141
|
+
}
|
|
142
|
+
export declare function defaultLedgerPath(): string;
|
|
@@ -0,0 +1,335 @@
|
|
|
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 plus the scoped mint token (owner-only) — never PAN, DPAN,
|
|
15
|
+
// CVC, or 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
|
+
const STALE_RESERVATION_MS = 5 * 60 * 1000;
|
|
40
|
+
// Mandates expired longer ago than this are pruned on the next create(). Generous
|
|
41
|
+
// (a week) so a recently-expired mandate is still visible in `mandate list`, but
|
|
42
|
+
// bounded so a long-lived heavy user cannot grow the owner-only ledger file into
|
|
43
|
+
// its size cap — which would otherwise brick EVERY ledger op (create/reserve/
|
|
44
|
+
// findCovering/commit/release all load the file first).
|
|
45
|
+
const EXPIRED_PRUNE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
46
|
+
function reservedTotal(record) {
|
|
47
|
+
return record.reservations.reduce((sum, r) => sum + r.amountMinor, 0);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Drop reservations older than STALE_RESERVATION_MS across every mandate — the
|
|
51
|
+
* debris of a crash between reserve and commit/release. Mutates in place and
|
|
52
|
+
* returns true if anything was swept (so the caller can decide to persist).
|
|
53
|
+
* A reservation with an unparseable `reservedAt` is treated as stale.
|
|
54
|
+
*/
|
|
55
|
+
function sweepStaleReservations(file, nowMs) {
|
|
56
|
+
let swept = false;
|
|
57
|
+
for (const m of file.mandates) {
|
|
58
|
+
const kept = m.reservations.filter((r) => {
|
|
59
|
+
const reservedMs = Date.parse(r.reservedAt);
|
|
60
|
+
const stale = !Number.isFinite(reservedMs) || nowMs - reservedMs > STALE_RESERVATION_MS;
|
|
61
|
+
if (stale)
|
|
62
|
+
swept = true;
|
|
63
|
+
return !stale;
|
|
64
|
+
});
|
|
65
|
+
if (kept.length !== m.reservations.length)
|
|
66
|
+
m.reservations = kept;
|
|
67
|
+
}
|
|
68
|
+
return swept;
|
|
69
|
+
}
|
|
70
|
+
/** Available headroom = ceiling - committed - reserved. Integer minor units. */
|
|
71
|
+
export function remainingMinor(record) {
|
|
72
|
+
return record.ceilingMinor - record.spentMinor - reservedTotal(record);
|
|
73
|
+
}
|
|
74
|
+
function isExpired(record, now) {
|
|
75
|
+
const end = Date.parse(record.expiresAt);
|
|
76
|
+
return !Number.isFinite(end) || end <= now.getTime();
|
|
77
|
+
}
|
|
78
|
+
function committedDrawCount(record) {
|
|
79
|
+
return record.draws.filter((draw) => draw.status === 'committed').length;
|
|
80
|
+
}
|
|
81
|
+
function hasDrawCountHeadroom(record) {
|
|
82
|
+
return (record.maxDraws === undefined ||
|
|
83
|
+
committedDrawCount(record) + record.reservations.length < record.maxDraws);
|
|
84
|
+
}
|
|
85
|
+
function assertPositiveInteger(value, label) {
|
|
86
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
87
|
+
throw new Error(`${label} must be a positive integer (minor units)`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The persisted card-mandate ledger. All mutating operations are serialized on
|
|
92
|
+
* an in-process promise chain so two concurrent draws cannot both observe the
|
|
93
|
+
* same remaining balance (the read-modify-write is atomic within the process).
|
|
94
|
+
*/
|
|
95
|
+
export class MandateLedger {
|
|
96
|
+
path;
|
|
97
|
+
chain = Promise.resolve();
|
|
98
|
+
constructor(path = defaultLedgerPath()) {
|
|
99
|
+
this.path = path;
|
|
100
|
+
}
|
|
101
|
+
/** Serialize a read-modify-write so concurrent draws can't race the file. */
|
|
102
|
+
run(fn) {
|
|
103
|
+
const next = this.chain.then(fn, fn);
|
|
104
|
+
// Keep the chain alive even if this op rejects; swallow only the chain copy.
|
|
105
|
+
this.chain = next.then(() => undefined, () => undefined);
|
|
106
|
+
return next;
|
|
107
|
+
}
|
|
108
|
+
// `nowMs` is the caller's clock for the stale-reservation sweep. The sweep
|
|
109
|
+
// deliberately runs only on the headroom-DECIDING loads — reserve() and
|
|
110
|
+
// findCovering() — which are the paths that must not be blocked by
|
|
111
|
+
// crash-stranded reservation debris. commit(), release(), and markUnhonored()
|
|
112
|
+
// pass NO clock on purpose: sweeping while finalizing a reservation could drop
|
|
113
|
+
// the very reservation being committed/released. Pure snapshot reads (get) and
|
|
114
|
+
// create() are likewise clock-independent.
|
|
115
|
+
async load(nowMs) {
|
|
116
|
+
try {
|
|
117
|
+
const doc = await readOwnerOnlyJson(this.path, 'card mandate ledger', LEDGER_MAX_BYTES);
|
|
118
|
+
if (!doc || !Array.isArray(doc.mandates)) {
|
|
119
|
+
return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
|
|
120
|
+
}
|
|
121
|
+
// Self-heal: prune reservations stranded by a crash before commit/release
|
|
122
|
+
// so `remaining` reflects reality. In-memory here; a mutating caller then
|
|
123
|
+
// persists the pruned state via save().
|
|
124
|
+
if (nowMs !== undefined)
|
|
125
|
+
sweepStaleReservations(doc, nowMs);
|
|
126
|
+
return doc;
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// A missing ledger is the normal first-run state.
|
|
130
|
+
if (err.code === 'ENOENT') {
|
|
131
|
+
return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
|
|
132
|
+
}
|
|
133
|
+
throw err;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async save(file) {
|
|
137
|
+
await writeOwnerOnlyJson(this.path, file);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Append a freshly-created mandate record. `now` drives the expired-mandate
|
|
141
|
+
* prune below; it defaults to the wall clock but is injectable so callers (and
|
|
142
|
+
* tests) can pin it — every other mutating method takes the same clock, and a
|
|
143
|
+
* real `new Date()` here would otherwise make the prune non-deterministic
|
|
144
|
+
* against fixtures dated relative to a fixed reference time.
|
|
145
|
+
*/
|
|
146
|
+
create(record, now = new Date()) {
|
|
147
|
+
return this.run(async () => {
|
|
148
|
+
const file = await this.load();
|
|
149
|
+
if (file.mandates.some((m) => m.mandateId === record.mandateId)) {
|
|
150
|
+
throw new Error(`mandate ${record.mandateId} already exists in the ledger`);
|
|
151
|
+
}
|
|
152
|
+
// Bound ledger growth: drop mandates expired beyond the grace window. They
|
|
153
|
+
// are already invisible to `mandate list` and unselectable by findCovering,
|
|
154
|
+
// so nothing references them. A mandate with an unparseable expiry is kept
|
|
155
|
+
// (fail-safe — never prune what we cannot date).
|
|
156
|
+
const pruneBefore = now.getTime() - EXPIRED_PRUNE_GRACE_MS;
|
|
157
|
+
file.mandates = file.mandates.filter((m) => {
|
|
158
|
+
const exp = Date.parse(m.expiresAt);
|
|
159
|
+
return !Number.isFinite(exp) || exp >= pruneBefore;
|
|
160
|
+
});
|
|
161
|
+
file.mandates.push(record);
|
|
162
|
+
await this.save(file);
|
|
163
|
+
return record;
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
/** Read a single mandate (no lock — a snapshot copy). */
|
|
167
|
+
async get(mandateId) {
|
|
168
|
+
const file = await this.load();
|
|
169
|
+
return file.mandates.find((m) => m.mandateId === mandateId) ?? null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* First ACTIVE mandate (not expired) whose merchant + currency match and whose
|
|
173
|
+
* remaining headroom covers amountMinor. Used by pay_merchant to decide the
|
|
174
|
+
* tap-free draw path vs a fresh per-purchase tap.
|
|
175
|
+
*/
|
|
176
|
+
async findCovering(query) {
|
|
177
|
+
const now = query.now ?? new Date();
|
|
178
|
+
const file = await this.load(now.getTime());
|
|
179
|
+
// Multiple mandates can now cover one merchant: a merchant-scoped mandate for
|
|
180
|
+
// this host AND any crossMerchant (budget) mandate both qualify (#6003). A
|
|
181
|
+
// mandate the network refused is skipped via `!m.unhonoredAt` so it can never
|
|
182
|
+
// be re-selected. A register-failed mandate is skipped the same way
|
|
183
|
+
// (`!m.registerFailedAt`): it has no server row, so a delegated draw would 404
|
|
184
|
+
// `no_mandate` — better to fall through to a fresh per-purchase tap.
|
|
185
|
+
// Selection order among covering candidates:
|
|
186
|
+
// 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
|
|
187
|
+
// spend the dedicated grant for this merchant first and keep the broader
|
|
188
|
+
// broader retail budget for merchants that have no scoped mandate. Draining
|
|
189
|
+
// the budget for a purchase a scoped mandate already covers both wastes
|
|
190
|
+
// the general headroom and can later force a fresh tap at another merchant.
|
|
191
|
+
// 2. Then the MOST headroom, then the latest expiry — never let a near-empty
|
|
192
|
+
// or near-expiry mandate get selected over a fuller, longer-lived sibling
|
|
193
|
+
// and then fail a draw the sibling would have covered.
|
|
194
|
+
const candidates = file.mandates.filter((m) =>
|
|
195
|
+
// A crossMerchant retail budget is locally cross-host; a merchant-scoped
|
|
196
|
+
// one only matches its own host. Network Retail/5999 eligibility is
|
|
197
|
+
// enforced downstream and is not inferred from a website hostname.
|
|
198
|
+
(m.crossMerchant || m.merchantHost === query.merchantHost) &&
|
|
199
|
+
m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
|
|
200
|
+
!m.unhonoredAt &&
|
|
201
|
+
!m.registerFailedAt &&
|
|
202
|
+
!isExpired(m, now) &&
|
|
203
|
+
hasDrawCountHeadroom(m) &&
|
|
204
|
+
remainingMinor(m) >= query.amountMinor);
|
|
205
|
+
candidates.sort((a, b) =>
|
|
206
|
+
// A scoped mandate (crossMerchant falsy → 0) sorts before a budget one (1).
|
|
207
|
+
(a.crossMerchant ? 1 : 0) - (b.crossMerchant ? 1 : 0) ||
|
|
208
|
+
remainingMinor(b) - remainingMinor(a) ||
|
|
209
|
+
Date.parse(b.expiresAt) - Date.parse(a.expiresAt));
|
|
210
|
+
return candidates[0] ?? null;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Mark a mandate as unhonored — the network declined a ceiling-scoped draw
|
|
214
|
+
* against it, so it must never be selected again. Atomic, owner-only write on
|
|
215
|
+
* the same serialized chain as every other mutation. Idempotent: a second
|
|
216
|
+
* call keeps the first timestamp. Throws only if the mandate is unknown.
|
|
217
|
+
*/
|
|
218
|
+
markUnhonored(mandateId, now = new Date()) {
|
|
219
|
+
return this.run(async () => {
|
|
220
|
+
const file = await this.load();
|
|
221
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
222
|
+
if (!record)
|
|
223
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
224
|
+
if (!record.unhonoredAt) {
|
|
225
|
+
record.unhonoredAt = now.toISOString();
|
|
226
|
+
await this.save(file);
|
|
227
|
+
}
|
|
228
|
+
return record;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Mark a mandate as register-failed — the delegated-draw register handshake
|
|
233
|
+
* did not create the server row at mandate-start, so it must never be selected
|
|
234
|
+
* for a (delegated) draw. Atomic, owner-only write on the same serialized chain
|
|
235
|
+
* as every other mutation. Idempotent: a second call keeps the first timestamp.
|
|
236
|
+
* Throws only if the mandate is unknown.
|
|
237
|
+
*/
|
|
238
|
+
markRegisterFailed(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.registerFailedAt) {
|
|
245
|
+
record.registerFailedAt = now.toISOString();
|
|
246
|
+
await this.save(file);
|
|
247
|
+
}
|
|
248
|
+
return record;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Atomically reserve headroom for a draw. Fail-closed: refuses when the
|
|
253
|
+
* mandate is unknown, expired, or the amount exceeds remaining headroom. The
|
|
254
|
+
* reservation counts against availability immediately, closing the window in
|
|
255
|
+
* which two concurrent draws both see the same remaining balance.
|
|
256
|
+
*/
|
|
257
|
+
reserve(mandateId, amountMinor, now = new Date()) {
|
|
258
|
+
return this.run(async () => {
|
|
259
|
+
assertPositiveInteger(amountMinor, 'draw amount');
|
|
260
|
+
// Sweep with the draw's clock so a crash-stranded reservation cannot block
|
|
261
|
+
// a legitimate draw; the save() below persists the pruned state.
|
|
262
|
+
const file = await this.load(now.getTime());
|
|
263
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
264
|
+
if (!record)
|
|
265
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
266
|
+
if (isExpired(record, now)) {
|
|
267
|
+
throw new Error(`mandate ${mandateId} expired at ${record.expiresAt}`);
|
|
268
|
+
}
|
|
269
|
+
if (amountMinor > remainingMinor(record)) {
|
|
270
|
+
throw new Error(`draw ${amountMinor} exceeds remaining budget ${remainingMinor(record)} (minor units)`);
|
|
271
|
+
}
|
|
272
|
+
if (!hasDrawCountHeadroom(record)) {
|
|
273
|
+
throw new Error(`mandate ${mandateId} reached its approved purchase-count limit`);
|
|
274
|
+
}
|
|
275
|
+
const reservationId = `rsv_${randomBytes(9).toString('base64url')}`;
|
|
276
|
+
record.reservations.push({ reservationId, amountMinor, reservedAt: now.toISOString() });
|
|
277
|
+
await this.save(file);
|
|
278
|
+
return reservationId;
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
/** Commit a reservation to permanent spend and record the payable draw. */
|
|
282
|
+
commit(mandateId, reservationId, meta, now = new Date()) {
|
|
283
|
+
return this.run(async () => {
|
|
284
|
+
const file = await this.load();
|
|
285
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
286
|
+
if (!record)
|
|
287
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
288
|
+
const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
|
|
289
|
+
if (idx === -1) {
|
|
290
|
+
throw new Error(`reservation ${reservationId} is not open (already committed or released)`);
|
|
291
|
+
}
|
|
292
|
+
const [reservation] = record.reservations.splice(idx, 1);
|
|
293
|
+
record.spentMinor += reservation.amountMinor;
|
|
294
|
+
record.draws.push({
|
|
295
|
+
drawId: `drw_${randomBytes(9).toString('base64url')}`,
|
|
296
|
+
reservationId,
|
|
297
|
+
amountMinor: reservation.amountMinor,
|
|
298
|
+
intentId: meta.intentId,
|
|
299
|
+
status: 'committed',
|
|
300
|
+
at: now.toISOString(),
|
|
301
|
+
});
|
|
302
|
+
await this.save(file);
|
|
303
|
+
return record;
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/** Release a reservation back to availability (draw failed / not payable). */
|
|
307
|
+
release(mandateId, reservationId, now = new Date()) {
|
|
308
|
+
return this.run(async () => {
|
|
309
|
+
const file = await this.load();
|
|
310
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
311
|
+
if (!record)
|
|
312
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
313
|
+
const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
|
|
314
|
+
if (idx === -1) {
|
|
315
|
+
// Idempotent by refusal: a committed/released reservation cannot be
|
|
316
|
+
// released again, but a redundant release must not corrupt state.
|
|
317
|
+
return record;
|
|
318
|
+
}
|
|
319
|
+
const [reservation] = record.reservations.splice(idx, 1);
|
|
320
|
+
record.draws.push({
|
|
321
|
+
drawId: `drw_${randomBytes(9).toString('base64url')}`,
|
|
322
|
+
reservationId,
|
|
323
|
+
amountMinor: reservation.amountMinor,
|
|
324
|
+
intentId: record.mandateId,
|
|
325
|
+
status: 'released',
|
|
326
|
+
at: now.toISOString(),
|
|
327
|
+
});
|
|
328
|
+
await this.save(file);
|
|
329
|
+
return record;
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
export function defaultLedgerPath() {
|
|
334
|
+
return (process.env.VISA_CARD_MANDATE_LEDGER_FILE ?? join(homedir(), '.visa-mcp', 'card-mandates.json'));
|
|
335
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export type MandateVerdict = {
|
|
19
|
+
ok: true;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
reason: string;
|
|
23
|
+
};
|
|
24
|
+
export declare function checkMandatePreFill(mandate: Mandate | null | undefined, ctx: MandatePreFillContext): MandateVerdict;
|
|
25
|
+
export declare function checkMandate(mandate: Mandate | null | undefined, ctx: MandateContext): MandateVerdict;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Mandate gate. The mandate is the user's pre-authorization for a purchase:
|
|
2
|
+
// how much, in what currency, at which merchant, until when. The gate is
|
|
3
|
+
// fail-closed: anything missing, expired, over cap, or mismatched refuses.
|
|
4
|
+
//
|
|
5
|
+
// The gate runs in two phases:
|
|
6
|
+
// pre-fill (checkMandatePreFill) — every fact knowable BEFORE a credential
|
|
7
|
+
// is minted: structure, expiry, merchant host, and the caller's
|
|
8
|
+
// asserted currency if any. No credential leaves the instrument
|
|
9
|
+
// and no field is filled without this passing. This is the
|
|
10
|
+
// boundary that matters once instruments mint live credentials:
|
|
11
|
+
// merchant JS sees field values on input, so filling first and
|
|
12
|
+
// gating at submit would already have exposed the credential.
|
|
13
|
+
// pre-submit (checkMandate) — the full check including the transaction
|
|
14
|
+
// amount and resolved currency, which are only known after the
|
|
15
|
+
// page total is read. No submit ever happens without this passing.
|
|
16
|
+
//
|
|
17
|
+
// All amounts are integer minor units (e.g. cents for USD). There is no
|
|
18
|
+
// floating-point money here on purpose; the executor reads a minor-unit total
|
|
19
|
+
// and compares integers.
|
|
20
|
+
function isInteger(n) {
|
|
21
|
+
return typeof n === 'number' && Number.isInteger(n);
|
|
22
|
+
}
|
|
23
|
+
// Pre-fill gate: everything knowable before a credential is minted.
|
|
24
|
+
export function checkMandatePreFill(mandate, ctx) {
|
|
25
|
+
if (!mandate) {
|
|
26
|
+
return { ok: false, reason: 'no mandate provided' };
|
|
27
|
+
}
|
|
28
|
+
// Structural validation: a malformed mandate must never authorize a spend.
|
|
29
|
+
if (!isInteger(mandate.maxAmountMinor) || mandate.maxAmountMinor <= 0) {
|
|
30
|
+
return { ok: false, reason: 'mandate maxAmountMinor must be a positive integer (minor units)' };
|
|
31
|
+
}
|
|
32
|
+
if (!mandate.currency) {
|
|
33
|
+
return { ok: false, reason: 'mandate is missing a currency' };
|
|
34
|
+
}
|
|
35
|
+
if (!mandate.expiresAt) {
|
|
36
|
+
return { ok: false, reason: 'mandate is missing an expiry' };
|
|
37
|
+
}
|
|
38
|
+
const expiry = new Date(mandate.expiresAt);
|
|
39
|
+
if (Number.isNaN(expiry.getTime())) {
|
|
40
|
+
return { ok: false, reason: `mandate expiry is not a valid date: ${mandate.expiresAt}` };
|
|
41
|
+
}
|
|
42
|
+
const now = ctx.now ?? new Date();
|
|
43
|
+
if (expiry.getTime() <= now.getTime()) {
|
|
44
|
+
return { ok: false, reason: `mandate expired at ${mandate.expiresAt}` };
|
|
45
|
+
}
|
|
46
|
+
if (ctx.currency && mandate.currency.toUpperCase() !== ctx.currency.toUpperCase()) {
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
reason: `currency mismatch: mandate ${mandate.currency} vs transaction ${ctx.currency}`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (mandate.merchantHost &&
|
|
53
|
+
normalizeHost(mandate.merchantHost) !== normalizeHost(ctx.merchantHost)) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
reason: `merchant host mismatch: mandate ${mandate.merchantHost} vs checkout ${ctx.merchantHost}`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
return { ok: true };
|
|
60
|
+
}
|
|
61
|
+
// Full (pre-submit) gate: the pre-fill checks plus amount and the resolved
|
|
62
|
+
// transaction currency.
|
|
63
|
+
export function checkMandate(mandate, ctx) {
|
|
64
|
+
const pre = checkMandatePreFill(mandate, ctx);
|
|
65
|
+
if (!pre.ok)
|
|
66
|
+
return pre;
|
|
67
|
+
if (!mandate)
|
|
68
|
+
return { ok: false, reason: 'no mandate provided' };
|
|
69
|
+
// The pre-fill phase skips the currency check when none is asserted yet;
|
|
70
|
+
// here the resolved currency is mandatory.
|
|
71
|
+
if (!ctx.currency) {
|
|
72
|
+
return { ok: false, reason: 'transaction currency could not be determined' };
|
|
73
|
+
}
|
|
74
|
+
// Transaction amount must be a clean integer minor-unit value.
|
|
75
|
+
if (!isInteger(ctx.amountMinor) || ctx.amountMinor < 0) {
|
|
76
|
+
return { ok: false, reason: 'transaction amount is not a non-negative integer (minor units)' };
|
|
77
|
+
}
|
|
78
|
+
if (ctx.amountMinor > mandate.maxAmountMinor) {
|
|
79
|
+
return {
|
|
80
|
+
ok: false,
|
|
81
|
+
reason: `amount ${ctx.amountMinor} exceeds mandate cap ${mandate.maxAmountMinor} (minor units)`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return { ok: true };
|
|
85
|
+
}
|
|
86
|
+
function normalizeHost(host) {
|
|
87
|
+
// Reduce a scheme/path/port-bearing host to its bare hostname for the
|
|
88
|
+
// mandate-vs-checkout equality gate. Uses a linear `split('/')` to drop the
|
|
89
|
+
// path segment instead of a greedy `/\/.*$/` replace: on a merchant-supplied
|
|
90
|
+
// host with many leading slashes the greedy variant is a polynomial-ReDoS
|
|
91
|
+
// vector (CodeQL js/polynomial-redos), and `split` is exact-equivalent here —
|
|
92
|
+
// "everything before the first '/'". The remaining regexes are start/end
|
|
93
|
+
// anchored, so they backtrack linearly.
|
|
94
|
+
const noScheme = host
|
|
95
|
+
.trim()
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/^https?:\/\//, '');
|
|
98
|
+
const noPath = noScheme.split('/', 1)[0];
|
|
99
|
+
return noPath.replace(/:\d+$/, '');
|
|
100
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Page } from 'playwright-core';
|
|
2
|
+
export type OutcomeStatus = 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'processing' | 'unknown';
|
|
3
|
+
export type OutcomeClassification = {
|
|
4
|
+
status: OutcomeStatus;
|
|
5
|
+
/** The named pattern that decided the status, e.g. "body:card-declined". */
|
|
6
|
+
signal: string | null;
|
|
7
|
+
};
|
|
8
|
+
/** Pure classification of one page state (case-insensitive on all inputs). */
|
|
9
|
+
export declare function classifyOutcomePage(url: string, bodyText: string, frameUrls?: readonly string[]): OutcomeClassification;
|
|
10
|
+
export type ObservedOutcome = {
|
|
11
|
+
status: 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'unknown';
|
|
12
|
+
signal: string | null;
|
|
13
|
+
/** Last non-final classification when the deadline expired ('processing' | 'unknown'). */
|
|
14
|
+
lastSeen: OutcomeStatus;
|
|
15
|
+
attempts: number;
|
|
16
|
+
elapsedMs: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Watch the page until a definitive outcome or the deadline. Polling (rather
|
|
20
|
+
* than waiting for one navigation) is deliberate: declines often render in
|
|
21
|
+
* place with NO navigation, while confirmations may arrive after several
|
|
22
|
+
* redirects — both resolve here the moment their signal appears. A detected
|
|
23
|
+
* issuer challenge is equally final for this observer: it will not resolve
|
|
24
|
+
* itself, so waiting out the deadline would only misreport it as unknown.
|
|
25
|
+
*/
|
|
26
|
+
export declare function observeOutcome(page: Page, opts?: {
|
|
27
|
+
deadlineMs?: number;
|
|
28
|
+
pollMs?: number;
|
|
29
|
+
holdThroughChallenge?: boolean;
|
|
30
|
+
}): Promise<ObservedOutcome>;
|