@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.
@@ -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
- return { registerFailed: false };
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 use a fresh ` +
180
- `per-purchase passkey tap. Re-run 'mandate start' to try again.\n`
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 passkey approval',
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 passkey tap`, { cause: err });
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
- const timeoutError = () => new Error(`no passkey approval within ${Math.round(timeoutMs / 1000)}s — ` +
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
- : 'passkey approval received from the hosted page.');
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