@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.
- package/README.md +202 -46
- package/dist/checkout-engine/adapters/generic.d.ts +69 -0
- package/dist/checkout-engine/adapters/generic.js +383 -58
- package/dist/checkout-engine/adapters/index.d.ts +4 -1
- package/dist/checkout-engine/adapters/index.js +10 -3
- package/dist/checkout-engine/adapters/shopify.d.ts +80 -0
- package/dist/checkout-engine/adapters/shopify.js +688 -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 +9 -4
- package/dist/checkout-engine/browser-launch.js +19 -4
- package/dist/checkout-engine/browserbase-browser.d.ts +24 -0
- package/dist/checkout-engine/browserbase-browser.js +186 -0
- package/dist/checkout-engine/cli-engine.d.ts +206 -32
- package/dist/checkout-engine/cli-engine.js +772 -216
- package/dist/checkout-engine/confirmed-merchants.d.ts +31 -0
- package/dist/checkout-engine/confirmed-merchants.js +165 -0
- package/dist/checkout-engine/detect.d.ts +1 -1
- package/dist/checkout-engine/detect.js +6 -0
- package/dist/checkout-engine/evidence.d.ts +1 -1
- package/dist/checkout-engine/executor.d.ts +61 -3
- package/dist/checkout-engine/executor.js +550 -144
- package/dist/checkout-engine/hosted-approval.d.ts +69 -9
- package/dist/checkout-engine/hosted-approval.js +211 -21
- package/dist/checkout-engine/index.d.ts +6 -2
- package/dist/checkout-engine/index.js +5 -1
- package/dist/checkout-engine/instrument.d.ts +6 -0
- package/dist/checkout-engine/known-merchants.d.ts +10 -0
- package/dist/checkout-engine/known-merchants.js +38 -0
- package/dist/checkout-engine/live-fill-approval.d.ts +5 -11
- package/dist/checkout-engine/live-fill-approval.js +20 -34
- package/dist/checkout-engine/mandate/card-mandate.d.ts +6 -2
- package/dist/checkout-engine/mandate/card-mandate.js +10 -5
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +63 -23
- package/dist/checkout-engine/mandate/mandate-ledger.js +124 -17
- package/dist/checkout-engine/receipt-dir.d.ts +6 -0
- package/dist/checkout-engine/receipt-dir.js +8 -0
- package/dist/checkout-engine/receipt.d.ts +56 -2
- package/dist/checkout-engine/receipt.js +55 -16
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -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 +15 -2
- package/dist/checkout-engine/unresolved-charges.d.ts +34 -0
- package/dist/checkout-engine/unresolved-charges.js +134 -0
- package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +26 -7
- package/dist/checkout-engine/vgs-gateway/server-mint-client.js +43 -3
- package/dist/checkout-engine/vgs-live-instrument.d.ts +11 -35
- package/dist/checkout-engine/vgs-live-instrument.js +14 -74
- package/dist/checkout-engine/vic-confirmation.d.ts +18 -0
- package/dist/checkout-engine/vic-confirmation.js +9 -3
- package/dist/checkout-engine/web-bot-auth.d.ts +98 -0
- package/dist/checkout-engine/web-bot-auth.js +218 -0
- package/dist/cli.js +899 -387
- package/dist/mcp-apps/ucp-checkout.html +280 -0
- package/dist/mcp-server/index.js +725 -254
- package/dist/merchant-ucp-mcp/index.js +6 -0
- package/dist/skills/pair-visa-agent/RUNTIMES.md +122 -79
- package/dist/skills/pair-visa-agent/SKILL.md +433 -318
- package/dist/skills/pair-visa-agent/scripts/__tests__/setup.test.mjs +407 -0
- package/dist/skills/pair-visa-agent/scripts/setup.mjs +310 -30
- package/dist/skills/visa-shopify-checkout/SKILL.md +122 -0
- package/dist/skills/visa-shopify-checkout/references/evidence-and-states.md +37 -0
- package/dist/skills/visa-ucp-shopping/SKILL.md +86 -0
- package/dist/subway-direct.mjs +1 -0
- package/install.ps1 +7 -6
- package/install.sh +3 -3
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +31 -28
- package/server.json +4 -4
- package/dist/checkout-engine/inline-target.d.ts +0 -13
- package/dist/checkout-engine/inline-target.js +0 -37
- package/dist/checkout-engine/pay-args.d.ts +0 -14
- package/dist/checkout-engine/pay-args.js +0 -44
- package/dist/checkout-engine/pay.d.ts +0 -1
- package/dist/checkout-engine/pay.js +0 -13
- package/dist/checkout-engine/repo-env.d.ts +0 -11
- package/dist/checkout-engine/repo-env.js +0 -23
- package/dist/checkout-engine/run-live-fill.d.ts +0 -1
- package/dist/checkout-engine/run-live-fill.js +0 -493
- package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
- 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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
55
|
-
*
|
|
56
|
-
* `no_mandate`.
|
|
57
|
-
*
|
|
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
|
-
*
|
|
62
|
-
* The
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
/**
|
|
97
|
-
|
|
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
|
|
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
|
|
124
|
-
* reservation counts against availability immediately, closing
|
|
125
|
-
* which two concurrent draws both see the same remaining
|
|
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
|
|
15
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
133
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
|
183
|
-
// one only its own host.
|
|
184
|
-
//
|
|
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
|
|
240
|
-
* reservation counts against availability immediately, closing
|
|
241
|
-
* which two concurrent draws both see the same remaining
|
|
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,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
|
-
|
|
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
|
-
}):
|
|
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
|