@visa/cli 4.1.0-rc.107 → 4.1.0-rc.109
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/dist/checkout-engine/cli-engine.d.ts +6 -0
- package/dist/checkout-engine/cli-engine.js +66 -7
- package/dist/checkout-engine/executor.d.ts +1 -0
- package/dist/checkout-engine/executor.js +14 -1
- package/dist/checkout-engine/hosted-approval.js +7 -2
- package/dist/checkout-engine/mandate/mandate-ledger.d.ts +21 -0
- package/dist/checkout-engine/mandate/mandate-ledger.js +34 -0
- package/dist/cli.js +178 -178
- package/dist/mcp-server/index.js +147 -147
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -177,6 +177,12 @@ export interface CardMandateRegisterSeam {
|
|
|
177
177
|
}) => Promise<{
|
|
178
178
|
ok: boolean;
|
|
179
179
|
reason?: string;
|
|
180
|
+
/**
|
|
181
|
+
* The ceiling the server committed — `min(requested, the owner's live card
|
|
182
|
+
* grant cap)`. Omitted by an older auth that does not report it, in which
|
|
183
|
+
* case the requested ceiling stands.
|
|
184
|
+
*/
|
|
185
|
+
approvedCeilingMinor?: number;
|
|
180
186
|
}>;
|
|
181
187
|
}
|
|
182
188
|
export type CliEngineDeps = {
|
|
@@ -16,7 +16,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
16
16
|
import { launchCheckoutBrowser } from './browser-launch.js';
|
|
17
17
|
import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, InMemoryPreparedCheckoutStore, } from './executor.js';
|
|
18
18
|
import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval, } from './hosted-approval.js';
|
|
19
|
-
import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, } from './vgs-live-instrument.js';
|
|
19
|
+
import { VgsAssuranceInstrument, VgsLiveInstrument, decimalToMinor, minorToDecimal, } from './vgs-live-instrument.js';
|
|
20
20
|
import { serverCreateIntent, serverFetchCryptogram, serverPostConfirmation, } from './vgs-gateway/server-mint-client.js';
|
|
21
21
|
import { createCardMandate, DEFAULT_MANDATE_MAX_DRAWS, drawFromMandate, MandateDrawDeclinedError, } from './mandate/card-mandate.js';
|
|
22
22
|
import { MandateLedger } from './mandate/mandate-ledger.js';
|
|
@@ -140,6 +140,21 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
140
140
|
transactionCurrencyCode: input.currency,
|
|
141
141
|
};
|
|
142
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Restate the facts at the ceiling the SERVER approved, when it lowered the
|
|
145
|
+
* one that was requested. Nothing has been spent or reserved on a mandate this
|
|
146
|
+
* new, so its remaining headroom IS its ceiling — see `createCardMandate`,
|
|
147
|
+
* which records `spentMinor: 0` with no reservations.
|
|
148
|
+
*
|
|
149
|
+
* Only ever lowers, mirroring `applyApprovedCeiling`: the server clamps
|
|
150
|
+
* downward, so a higher number means an unexpected response and is ignored
|
|
151
|
+
* rather than reported as headroom no human approved.
|
|
152
|
+
*/
|
|
153
|
+
function approvedCeilingFacts(facts, approvedCeilingMinor) {
|
|
154
|
+
if (approvedCeilingMinor === undefined || approvedCeilingMinor >= facts.ceilingMinor)
|
|
155
|
+
return {};
|
|
156
|
+
return { ceilingMinor: approvedCeilingMinor, remainingMinor: approvedCeilingMinor };
|
|
157
|
+
}
|
|
143
158
|
// Seed the one server-authoritative cumulative store keyed by the VGS intent
|
|
144
159
|
// ID (#5942). On failure the local record is marked register-failed so
|
|
145
160
|
// findCovering() SKIPS it — the mandate exists but is never drawn tap-free —
|
|
@@ -162,8 +177,50 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
162
177
|
ok: false,
|
|
163
178
|
reason: err instanceof Error ? err.message : String(err),
|
|
164
179
|
}));
|
|
165
|
-
if (reg.ok)
|
|
166
|
-
|
|
180
|
+
if (reg.ok) {
|
|
181
|
+
// ONE SPENDING LIMIT: auth just clamped the requested ceiling to the
|
|
182
|
+
// owner's live grant cap and told us what it committed. Adopt it, so the
|
|
183
|
+
// local record — which findCovering() selects on and `mandate list`
|
|
184
|
+
// prints — states the budget the owner actually approved. Best-effort:
|
|
185
|
+
// the mandate is registered and drawable either way, and auth's verdict
|
|
186
|
+
// remains the enforcing cap, so a failed local write must not abort the
|
|
187
|
+
// ceremony. It leaves the record overstating headroom, which is exactly
|
|
188
|
+
// the pre-existing behaviour.
|
|
189
|
+
//
|
|
190
|
+
// If that write fails the returned facts still carry the approved figure —
|
|
191
|
+
// telling the human the truth beats echoing a ceiling their grant refused,
|
|
192
|
+
// and the ledger is left exactly as overstated as it was before this
|
|
193
|
+
// existed. But the two then disagree, so say so out loud rather than let
|
|
194
|
+
// `mandate list` quietly contradict what `mandate start` just printed.
|
|
195
|
+
// Same posture as the mark-failed escalation below.
|
|
196
|
+
if (reg.approvedCeilingMinor !== undefined) {
|
|
197
|
+
const applied = await ledger
|
|
198
|
+
.applyApprovedCeiling(args.mandateId, reg.approvedCeilingMinor)
|
|
199
|
+
.then(() => true)
|
|
200
|
+
.catch(() => false);
|
|
201
|
+
if (!applied) {
|
|
202
|
+
// Rendered through minorToDecimal, never raw /100 — and only when the
|
|
203
|
+
// value is a sane integer, because THIS branch is also where an
|
|
204
|
+
// unusable value lands (applyApprovedCeiling rejects it). A warning
|
|
205
|
+
// must not throw on its way out. The currency is omitted deliberately:
|
|
206
|
+
// both mandate paths refuse anything but USD long before register, so
|
|
207
|
+
// it is known, and passing it would let a non-2-decimal code throw here.
|
|
208
|
+
const approved = Number.isSafeInteger(reg.approvedCeilingMinor) && reg.approvedCeilingMinor > 0
|
|
209
|
+
? ` (${args.currency} ${minorToDecimal(reg.approvedCeilingMinor)})`
|
|
210
|
+
: '';
|
|
211
|
+
process.stderr.write(`warning: your owner's approved limit for this budget${approved} could NOT be saved ` +
|
|
212
|
+
`locally — 'mandate list' will overstate the remaining balance until you re-run ` +
|
|
213
|
+
`'mandate start'. Spending is still capped at the approved limit; a draw over it ` +
|
|
214
|
+
`is refused. mandateId=${args.mandateId}\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
registerFailed: false,
|
|
219
|
+
...(reg.approvedCeilingMinor !== undefined
|
|
220
|
+
? { approvedCeilingMinor: reg.approvedCeilingMinor }
|
|
221
|
+
: {}),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
167
224
|
// Register failed: the server `card_mandate_spend` row was never created, so
|
|
168
225
|
// a delegated draw against this mandate would 404 `no_mandate`. Mark it
|
|
169
226
|
// register-failed so findCovering() SKIPS it and the owner's next checkout
|
|
@@ -176,8 +233,8 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
176
233
|
.catch(() => false);
|
|
177
234
|
process.stderr.write(marked
|
|
178
235
|
? `warning: card-mandate register failed (${reg.reason ?? 'unknown'}) — this mandate ` +
|
|
179
|
-
`will NOT be used for tap-free draws; your next checkout will
|
|
180
|
-
`per-purchase
|
|
236
|
+
`will NOT be used for tap-free draws; your next checkout will interrupt the owner ` +
|
|
237
|
+
`for a fresh per-purchase approval. Re-run 'mandate start' to try again.\n`
|
|
181
238
|
: // Escalate: the mark write ALSO failed, so the mandate is persisted but
|
|
182
239
|
// NOT disabled — findCovering could still select an undrawable mandate.
|
|
183
240
|
// Tell the owner loudly not to rely on it and how to recover.
|
|
@@ -293,6 +350,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
293
350
|
});
|
|
294
351
|
return {
|
|
295
352
|
...facts,
|
|
353
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
296
354
|
merchantHost: new URL(merchant.url).hostname,
|
|
297
355
|
registerFailed: registered.registerFailed,
|
|
298
356
|
...(registered.registerFailureReason !== undefined
|
|
@@ -353,6 +411,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
353
411
|
});
|
|
354
412
|
return {
|
|
355
413
|
...facts,
|
|
414
|
+
...approvedCeilingFacts(facts, registered.approvedCeilingMinor),
|
|
356
415
|
merchantHost: new URL(claim.merchant.url).hostname,
|
|
357
416
|
registerFailed: registered.registerFailed,
|
|
358
417
|
...(registered.registerFailureReason !== undefined
|
|
@@ -564,7 +623,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
564
623
|
confirmationRef: null,
|
|
565
624
|
receiptPath: null,
|
|
566
625
|
detail: 'this mandate cannot draw because its delegated card authority is unavailable; ' +
|
|
567
|
-
'restore the runtime binding or use a fresh per-purchase
|
|
626
|
+
'restore the runtime binding or use a fresh per-purchase approval',
|
|
568
627
|
vicConfirmation: null,
|
|
569
628
|
source,
|
|
570
629
|
remainingMinor: null,
|
|
@@ -662,7 +721,7 @@ export function createCliCheckoutEngine(deps = {}) {
|
|
|
662
721
|
}
|
|
663
722
|
throw new Error(verdictFailure.transient
|
|
664
723
|
? `the card-mandate draw could not be authorized right now (${verdictFailure.reasons.join(', ') || 'temporary error'}) — retry shortly`
|
|
665
|
-
: `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase
|
|
724
|
+
: `this card mandate can no longer be drawn (${verdictFailure.reasons.join(', ') || 'refused'}); it has been disabled — retry the checkout to use a fresh per-purchase approval`, { cause: err });
|
|
666
725
|
}
|
|
667
726
|
if (!isTransientDrawFailure(err.cause ?? err)) {
|
|
668
727
|
await ledger.markUnhonored(mandateId, now());
|
|
@@ -93,6 +93,7 @@ export type SubmitApprovedCheckoutOptions = {
|
|
|
93
93
|
onChallengeHold?: (signal: string | null) => void;
|
|
94
94
|
resolveEmailOtp?: OtpResolver;
|
|
95
95
|
};
|
|
96
|
+
export declare function snapshotOrigin(rawUrl: string): string;
|
|
96
97
|
export type PreparedCheckoutSession = {
|
|
97
98
|
checkout: PreparedCheckout;
|
|
98
99
|
context: BrowserContext;
|
|
@@ -145,6 +145,19 @@ async function findSubmit(page) {
|
|
|
145
145
|
}
|
|
146
146
|
return null;
|
|
147
147
|
}
|
|
148
|
+
// The diagnostic snapshot records the page ORIGIN only, never the full URL: a
|
|
149
|
+
// payment-session path/query (e.g. a live Stripe `cs_live_...` checkout-session
|
|
150
|
+
// id) must not be retained in the local receipt, which elsewhere promises
|
|
151
|
+
// "hostname only" (#7101). Falls back to the raw value only if it does not parse
|
|
152
|
+
// as a URL (never a real page.url()).
|
|
153
|
+
export function snapshotOrigin(rawUrl) {
|
|
154
|
+
try {
|
|
155
|
+
return new URL(rawUrl).origin;
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
148
161
|
async function snapshotSummary(page) {
|
|
149
162
|
const info = await page
|
|
150
163
|
.evaluate(() => {
|
|
@@ -153,7 +166,7 @@ async function snapshotSummary(page) {
|
|
|
153
166
|
return { title: document.title, heading: heading?.textContent?.trim() || '', body };
|
|
154
167
|
})
|
|
155
168
|
.catch(() => ({ title: '', heading: '', body: '' }));
|
|
156
|
-
return `url=${page.url()} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
|
|
169
|
+
return `url=${snapshotOrigin(page.url())} title="${info.title}" heading="${info.heading}" body="${info.body}"`;
|
|
157
170
|
}
|
|
158
171
|
async function readConfirmationRef(page) {
|
|
159
172
|
const ref = await page
|
|
@@ -155,7 +155,12 @@ export async function runHostedApproval(opts) {
|
|
|
155
155
|
const verifier = randomBytes(32).toString('base64url');
|
|
156
156
|
const challenge = createHash('sha256').update(verifier, 'utf8').digest('base64url');
|
|
157
157
|
const deadline = now() + timeoutMs;
|
|
158
|
-
|
|
158
|
+
// Verification-neutral: what the page asks the human for is a per-card
|
|
159
|
+
// property (passkey ceremony, issuer one-time code, or nothing beyond the
|
|
160
|
+
// signed-in approval) that this runner never learns. Naming a passkey here
|
|
161
|
+
// told operators on code-verified cards to wait for a device prompt that was
|
|
162
|
+
// never coming, and then blamed the timeout on the step they never saw.
|
|
163
|
+
const timeoutError = () => new Error(`no approval within ${Math.round(timeoutMs / 1000)}s — ` +
|
|
159
164
|
'the checkout was cancelled; run again to retry');
|
|
160
165
|
// Bound EVERY request by the remaining deadline: race the fetch against the
|
|
161
166
|
// (injectable) sleep and abort the request when the deadline wins, so a
|
|
@@ -312,7 +317,7 @@ export async function runHostedApproval(opts) {
|
|
|
312
317
|
}
|
|
313
318
|
log(assuranceExempt
|
|
314
319
|
? 'approval received from the hosted page (code-verified card, no passkey).'
|
|
315
|
-
: '
|
|
320
|
+
: 'approval received from the hosted page (passkey-verified card).');
|
|
316
321
|
return {
|
|
317
322
|
...assuranceFromCeremony(target, doc.assuranceData ?? null),
|
|
318
323
|
...(assuranceExempt ? { assuranceExempt: true } : {}),
|
|
@@ -127,6 +127,27 @@ export declare class MandateLedger {
|
|
|
127
127
|
* Throws only if the mandate is unknown.
|
|
128
128
|
*/
|
|
129
129
|
markRegisterFailed(mandateId: string, now?: Date): Promise<CardMandateRecord>;
|
|
130
|
+
/**
|
|
131
|
+
* ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
|
|
132
|
+
*
|
|
133
|
+
* The requested ceiling is only a request. At register, auth clamps it to the
|
|
134
|
+
* owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
|
|
135
|
+
* that. The local record used to keep the requested figure, so a runtime whose
|
|
136
|
+
* request exceeded the grant reported — and selected against — a budget the
|
|
137
|
+
* owner never approved. The draw still failed closed at auth's verdict, so it
|
|
138
|
+
* was never over-spend; it was the runtime lying about its own headroom and
|
|
139
|
+
* then hitting a decline it could not explain.
|
|
140
|
+
*
|
|
141
|
+
* LOWERS ONLY. A value at or above the current ceiling is ignored, not
|
|
142
|
+
* written: the server clamps downward, so a higher number means an unexpected
|
|
143
|
+
* response, and honouring it would let a client-observed value hand a runtime
|
|
144
|
+
* headroom no human approved. Cap authority stays server-side either way —
|
|
145
|
+
* this only stops the local copy from overstating it.
|
|
146
|
+
*
|
|
147
|
+
* Atomic and idempotent on the same serialized chain as every other mutation.
|
|
148
|
+
* Throws only if the mandate is unknown.
|
|
149
|
+
*/
|
|
150
|
+
applyApprovedCeiling(mandateId: string, approvedCeilingMinor: number): Promise<CardMandateRecord>;
|
|
130
151
|
/**
|
|
131
152
|
* Atomically reserve headroom for a draw. Fail-closed: refuses when the
|
|
132
153
|
* mandate is unknown, expired, or the amount exceeds remaining headroom. The
|
|
@@ -252,6 +252,40 @@ export class MandateLedger {
|
|
|
252
252
|
return record;
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
|
+
/**
|
|
256
|
+
* ONE SPENDING LIMIT: adopt the ceiling the SERVER actually approved.
|
|
257
|
+
*
|
|
258
|
+
* The requested ceiling is only a request. At register, auth clamps it to the
|
|
259
|
+
* owner's live card-grant cap (`min(requested, grant daily limit)`) and writes
|
|
260
|
+
* that. The local record used to keep the requested figure, so a runtime whose
|
|
261
|
+
* request exceeded the grant reported — and selected against — a budget the
|
|
262
|
+
* owner never approved. The draw still failed closed at auth's verdict, so it
|
|
263
|
+
* was never over-spend; it was the runtime lying about its own headroom and
|
|
264
|
+
* then hitting a decline it could not explain.
|
|
265
|
+
*
|
|
266
|
+
* LOWERS ONLY. A value at or above the current ceiling is ignored, not
|
|
267
|
+
* written: the server clamps downward, so a higher number means an unexpected
|
|
268
|
+
* response, and honouring it would let a client-observed value hand a runtime
|
|
269
|
+
* headroom no human approved. Cap authority stays server-side either way —
|
|
270
|
+
* this only stops the local copy from overstating it.
|
|
271
|
+
*
|
|
272
|
+
* Atomic and idempotent on the same serialized chain as every other mutation.
|
|
273
|
+
* Throws only if the mandate is unknown.
|
|
274
|
+
*/
|
|
275
|
+
applyApprovedCeiling(mandateId, approvedCeilingMinor) {
|
|
276
|
+
return this.run(async () => {
|
|
277
|
+
assertPositiveInteger(approvedCeilingMinor, 'approved mandate ceiling');
|
|
278
|
+
const file = await this.load();
|
|
279
|
+
const record = file.mandates.find((m) => m.mandateId === mandateId);
|
|
280
|
+
if (!record)
|
|
281
|
+
throw new Error(`no such mandate ${mandateId}`);
|
|
282
|
+
if (approvedCeilingMinor >= record.ceilingMinor)
|
|
283
|
+
return record;
|
|
284
|
+
record.ceilingMinor = approvedCeilingMinor;
|
|
285
|
+
await this.save(file);
|
|
286
|
+
return record;
|
|
287
|
+
});
|
|
288
|
+
}
|
|
255
289
|
/**
|
|
256
290
|
* Atomically reserve headroom for a draw. Fail-closed: refuses when the
|
|
257
291
|
* mandate is unknown, expired, or the amount exceeds remaining headroom. The
|