@visa/cli 4.1.0-rc.13 → 4.1.0-rc.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +188 -232
  2. package/dist/checkout-engine/adapters/generic.d.ts +4 -0
  3. package/dist/checkout-engine/adapters/generic.js +28 -13
  4. package/dist/checkout-engine/adapters/index.d.ts +4 -1
  5. package/dist/checkout-engine/adapters/index.js +10 -3
  6. package/dist/checkout-engine/adapters/shopify.d.ts +31 -0
  7. package/dist/checkout-engine/adapters/shopify.js +423 -0
  8. package/dist/checkout-engine/amount.d.ts +15 -0
  9. package/dist/checkout-engine/amount.js +72 -0
  10. package/dist/checkout-engine/cli-engine.d.ts +207 -2
  11. package/dist/checkout-engine/cli-engine.js +677 -27
  12. package/dist/checkout-engine/detect.d.ts +1 -1
  13. package/dist/checkout-engine/detect.js +26 -0
  14. package/dist/checkout-engine/evidence.d.ts +4 -1
  15. package/dist/checkout-engine/evidence.js +51 -6
  16. package/dist/checkout-engine/executor.d.ts +34 -4
  17. package/dist/checkout-engine/executor.js +266 -115
  18. package/dist/checkout-engine/hosted-approval.d.ts +133 -8
  19. package/dist/checkout-engine/hosted-approval.js +400 -49
  20. package/dist/checkout-engine/index.d.ts +4 -1
  21. package/dist/checkout-engine/index.js +3 -0
  22. package/dist/checkout-engine/instrument.d.ts +7 -0
  23. package/dist/checkout-engine/instrument.js +4 -0
  24. package/dist/checkout-engine/live-fill-approval.d.ts +0 -20
  25. package/dist/checkout-engine/live-fill-approval.js +15 -51
  26. package/dist/checkout-engine/mandate/card-mandate.d.ts +121 -0
  27. package/dist/checkout-engine/mandate/card-mandate.js +227 -0
  28. package/dist/checkout-engine/mandate/mandate-ledger.d.ts +165 -0
  29. package/dist/checkout-engine/mandate/mandate-ledger.js +373 -0
  30. package/dist/checkout-engine/outcome.d.ts +2 -2
  31. package/dist/checkout-engine/outcome.js +36 -1
  32. package/dist/checkout-engine/owner-only-file.d.ts +9 -0
  33. package/dist/checkout-engine/owner-only-file.js +20 -1
  34. package/dist/checkout-engine/trace-handles.d.ts +8 -0
  35. package/dist/checkout-engine/trace-handles.js +12 -0
  36. package/dist/checkout-engine/types.d.ts +20 -2
  37. package/dist/checkout-engine/vgs-gateway/server-mint-client.d.ts +82 -0
  38. package/dist/checkout-engine/vgs-gateway/server-mint-client.js +180 -0
  39. package/dist/checkout-engine/vgs-live-instrument.d.ts +38 -0
  40. package/dist/checkout-engine/vgs-live-instrument.js +52 -8
  41. package/dist/checkout-engine/vic-confirmation.js +2 -2
  42. package/dist/cli.js +579 -494
  43. package/dist/mcp-server/index.js +441 -176
  44. package/dist/skills/pair-visa-agent/RUNTIMES.md +92 -0
  45. package/dist/skills/pair-visa-agent/SKILL.md +467 -0
  46. package/dist/skills/pair-visa-agent/scripts/setup.mjs +48 -0
  47. package/install.ps1 +3 -41
  48. package/install.sh +4 -36
  49. package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
  50. package/package.json +16 -12
  51. package/server.json +3 -3
  52. package/dist/checkout-engine/inline-target.d.ts +0 -13
  53. package/dist/checkout-engine/inline-target.js +0 -37
  54. package/dist/checkout-engine/pay-args.d.ts +0 -14
  55. package/dist/checkout-engine/pay-args.js +0 -44
  56. package/dist/checkout-engine/pay.d.ts +0 -1
  57. package/dist/checkout-engine/pay.js +0 -13
  58. package/dist/checkout-engine/repo-env.d.ts +0 -11
  59. package/dist/checkout-engine/repo-env.js +0 -23
  60. package/dist/checkout-engine/run-live-fill.d.ts +0 -1
  61. package/dist/checkout-engine/run-live-fill.js +0 -443
  62. package/dist/checkout-engine/vgs-gateway/fetch-credential.d.mts +0 -74
  63. package/dist/checkout-engine/vgs-gateway/fetch-credential.mjs +0 -240
@@ -0,0 +1,373 @@
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
+ // Must match the server-authoritative TTL (CARD_MANDATE_RESERVATION_TTL_SECONDS
40
+ // in apps/auth/src/server.ts, default 15 min) to avoid a window where the local
41
+ // ledger over-reports available headroom.
42
+ const STALE_RESERVATION_MS = 15 * 60 * 1000;
43
+ // Mandates expired longer ago than this are pruned on the next create(). Generous
44
+ // (a week) so a recently-expired mandate is still visible in `mandate list`, but
45
+ // bounded so a long-lived heavy user cannot grow the owner-only ledger file into
46
+ // its size cap — which would otherwise brick EVERY ledger op (create/reserve/
47
+ // findCovering/commit/release all load the file first).
48
+ const EXPIRED_PRUNE_GRACE_MS = 7 * 24 * 60 * 60 * 1000;
49
+ function reservedTotal(record) {
50
+ return record.reservations.reduce((sum, r) => sum + r.amountMinor, 0);
51
+ }
52
+ /**
53
+ * Drop reservations older than STALE_RESERVATION_MS across every mandate — the
54
+ * debris of a crash between reserve and commit/release. Mutates in place and
55
+ * returns true if anything was swept (so the caller can decide to persist).
56
+ * A reservation with an unparseable `reservedAt` is treated as stale.
57
+ */
58
+ function sweepStaleReservations(file, nowMs) {
59
+ let swept = false;
60
+ for (const m of file.mandates) {
61
+ const kept = m.reservations.filter((r) => {
62
+ const reservedMs = Date.parse(r.reservedAt);
63
+ const stale = !Number.isFinite(reservedMs) || nowMs - reservedMs > STALE_RESERVATION_MS;
64
+ if (stale)
65
+ swept = true;
66
+ return !stale;
67
+ });
68
+ if (kept.length !== m.reservations.length)
69
+ m.reservations = kept;
70
+ }
71
+ return swept;
72
+ }
73
+ /** Available headroom = ceiling - committed - reserved. Integer minor units. */
74
+ export function remainingMinor(record) {
75
+ return record.ceilingMinor - record.spentMinor - reservedTotal(record);
76
+ }
77
+ function isExpired(record, now) {
78
+ const end = Date.parse(record.expiresAt);
79
+ return !Number.isFinite(end) || end <= now.getTime();
80
+ }
81
+ function committedDrawCount(record) {
82
+ return record.draws.filter((draw) => draw.status === 'committed').length;
83
+ }
84
+ function hasDrawCountHeadroom(record) {
85
+ return (record.maxDraws === undefined ||
86
+ committedDrawCount(record) + record.reservations.length < record.maxDraws);
87
+ }
88
+ function assertPositiveInteger(value, label) {
89
+ if (!Number.isSafeInteger(value) || value <= 0) {
90
+ throw new Error(`${label} must be a positive integer (minor units)`);
91
+ }
92
+ }
93
+ /**
94
+ * The persisted card-mandate ledger. All mutating operations are serialized on
95
+ * an in-process promise chain so two concurrent draws cannot both observe the
96
+ * same remaining balance (the read-modify-write is atomic within the process).
97
+ */
98
+ export class MandateLedger {
99
+ path;
100
+ chain = Promise.resolve();
101
+ constructor(path = defaultLedgerPath()) {
102
+ this.path = path;
103
+ }
104
+ /** Serialize a read-modify-write so concurrent draws can't race the file. */
105
+ run(fn) {
106
+ const next = this.chain.then(fn, fn);
107
+ // Keep the chain alive even if this op rejects; swallow only the chain copy.
108
+ this.chain = next.then(() => undefined, () => undefined);
109
+ return next;
110
+ }
111
+ // `nowMs` is the caller's clock for the stale-reservation sweep. The sweep
112
+ // deliberately runs only on the headroom-DECIDING loads — reserve() and
113
+ // findCovering() — which are the paths that must not be blocked by
114
+ // crash-stranded reservation debris. commit(), release(), and markUnhonored()
115
+ // pass NO clock on purpose: sweeping while finalizing a reservation could drop
116
+ // the very reservation being committed/released. Pure snapshot reads (get) and
117
+ // create() are likewise clock-independent.
118
+ async load(nowMs) {
119
+ try {
120
+ const doc = await readOwnerOnlyJson(this.path, 'card mandate ledger', LEDGER_MAX_BYTES);
121
+ if (!doc || !Array.isArray(doc.mandates)) {
122
+ return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
123
+ }
124
+ // Self-heal: prune reservations stranded by a crash before commit/release
125
+ // so `remaining` reflects reality. In-memory here; a mutating caller then
126
+ // persists the pruned state via save().
127
+ if (nowMs !== undefined)
128
+ sweepStaleReservations(doc, nowMs);
129
+ return doc;
130
+ }
131
+ catch (err) {
132
+ // A missing ledger is the normal first-run state.
133
+ if (err.code === 'ENOENT') {
134
+ return { version: CARD_MANDATE_LEDGER_VERSION, mandates: [] };
135
+ }
136
+ throw err;
137
+ }
138
+ }
139
+ async save(file) {
140
+ await writeOwnerOnlyJson(this.path, file);
141
+ }
142
+ /**
143
+ * Append a freshly-created mandate record. `now` drives the expired-mandate
144
+ * prune below; it defaults to the wall clock but is injectable so callers (and
145
+ * tests) can pin it — every other mutating method takes the same clock, and a
146
+ * real `new Date()` here would otherwise make the prune non-deterministic
147
+ * against fixtures dated relative to a fixed reference time.
148
+ */
149
+ create(record, now = new Date()) {
150
+ return this.run(async () => {
151
+ const file = await this.load();
152
+ if (file.mandates.some((m) => m.mandateId === record.mandateId)) {
153
+ throw new Error(`mandate ${record.mandateId} already exists in the ledger`);
154
+ }
155
+ // Bound ledger growth: drop mandates expired beyond the grace window. They
156
+ // are already invisible to `mandate list` and unselectable by findCovering,
157
+ // so nothing references them. A mandate with an unparseable expiry is kept
158
+ // (fail-safe — never prune what we cannot date).
159
+ const pruneBefore = now.getTime() - EXPIRED_PRUNE_GRACE_MS;
160
+ file.mandates = file.mandates.filter((m) => {
161
+ const exp = Date.parse(m.expiresAt);
162
+ return !Number.isFinite(exp) || exp >= pruneBefore;
163
+ });
164
+ file.mandates.push(record);
165
+ await this.save(file);
166
+ return record;
167
+ });
168
+ }
169
+ /** Read a single mandate (no lock — a snapshot copy). */
170
+ async get(mandateId) {
171
+ const file = await this.load();
172
+ return file.mandates.find((m) => m.mandateId === mandateId) ?? null;
173
+ }
174
+ /**
175
+ * First ACTIVE mandate (not expired) whose merchant + currency match and whose
176
+ * remaining headroom covers amountMinor. Used by pay_merchant to decide the
177
+ * tap-free draw path vs a fresh per-purchase tap.
178
+ */
179
+ async findCovering(query) {
180
+ const now = query.now ?? new Date();
181
+ const file = await this.load(now.getTime());
182
+ // Multiple mandates can now cover one merchant: a merchant-scoped mandate for
183
+ // this host AND any crossMerchant (budget) mandate both qualify (#6003). A
184
+ // mandate the network refused is skipped via `!m.unhonoredAt` so it can never
185
+ // be re-selected. A register-failed mandate is skipped the same way
186
+ // (`!m.registerFailedAt`): it has no server row, so a delegated draw would 404
187
+ // `no_mandate` — better to fall through to a fresh per-purchase tap.
188
+ // Selection order among covering candidates:
189
+ // 1. Prefer a merchant-SCOPED mandate over a crossMerchant budget one —
190
+ // spend the dedicated grant for this merchant first and keep the broader
191
+ // broader retail budget for merchants that have no scoped mandate. Draining
192
+ // the budget for a purchase a scoped mandate already covers both wastes
193
+ // the general headroom and can later force a fresh tap at another merchant.
194
+ // 2. Then the MOST headroom, then the latest expiry — never let a near-empty
195
+ // or near-expiry mandate get selected over a fuller, longer-lived sibling
196
+ // and then fail a draw the sibling would have covered.
197
+ const candidates = file.mandates.filter((m) =>
198
+ // A crossMerchant retail budget is locally cross-host; a merchant-scoped
199
+ // one only matches its own host. Network Retail/5999 eligibility is
200
+ // enforced downstream and is not inferred from a website hostname.
201
+ (m.crossMerchant || m.merchantHost === query.merchantHost) &&
202
+ m.agentJkt === query.agentJkt &&
203
+ m.currencyCode.toUpperCase() === query.currencyCode.toUpperCase() &&
204
+ !m.unhonoredAt &&
205
+ !m.registerFailedAt &&
206
+ !isExpired(m, now) &&
207
+ hasDrawCountHeadroom(m) &&
208
+ remainingMinor(m) >= query.amountMinor);
209
+ candidates.sort((a, b) =>
210
+ // A scoped mandate (crossMerchant falsy → 0) sorts before a budget one (1).
211
+ (a.crossMerchant ? 1 : 0) - (b.crossMerchant ? 1 : 0) ||
212
+ remainingMinor(b) - remainingMinor(a) ||
213
+ Date.parse(b.expiresAt) - Date.parse(a.expiresAt));
214
+ return candidates[0] ?? null;
215
+ }
216
+ /**
217
+ * Mark a mandate as unhonored — the network declined a ceiling-scoped draw
218
+ * against it, so it must never be selected again. Atomic, owner-only write on
219
+ * the same serialized chain as every other mutation. Idempotent: a second
220
+ * call keeps the first timestamp. Throws only if the mandate is unknown.
221
+ */
222
+ markUnhonored(mandateId, now = new Date()) {
223
+ return this.run(async () => {
224
+ const file = await this.load();
225
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
226
+ if (!record)
227
+ throw new Error(`no such mandate ${mandateId}`);
228
+ if (!record.unhonoredAt) {
229
+ record.unhonoredAt = now.toISOString();
230
+ await this.save(file);
231
+ }
232
+ return record;
233
+ });
234
+ }
235
+ /**
236
+ * Mark a mandate as register-failed — the delegated-draw register handshake
237
+ * did not create the server row at mandate-start, so it must never be selected
238
+ * for a (delegated) draw. Atomic, owner-only write on the same serialized chain
239
+ * as every other mutation. Idempotent: a second call keeps the first timestamp.
240
+ * Throws only if the mandate is unknown.
241
+ */
242
+ markRegisterFailed(mandateId, now = new Date()) {
243
+ return this.run(async () => {
244
+ const file = await this.load();
245
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
246
+ if (!record)
247
+ throw new Error(`no such mandate ${mandateId}`);
248
+ if (!record.registerFailedAt) {
249
+ record.registerFailedAt = now.toISOString();
250
+ await this.save(file);
251
+ }
252
+ return record;
253
+ });
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
+ }
289
+ /**
290
+ * Atomically reserve headroom for a draw. Fail-closed: refuses when the
291
+ * mandate is unknown, expired, or the amount exceeds remaining headroom. The
292
+ * reservation counts against availability immediately, closing the window in
293
+ * which two concurrent draws both see the same remaining balance.
294
+ */
295
+ reserve(mandateId, amountMinor, now = new Date()) {
296
+ return this.run(async () => {
297
+ assertPositiveInteger(amountMinor, 'draw amount');
298
+ // Sweep with the draw's clock so a crash-stranded reservation cannot block
299
+ // a legitimate draw; the save() below persists the pruned state.
300
+ const file = await this.load(now.getTime());
301
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
302
+ if (!record)
303
+ throw new Error(`no such mandate ${mandateId}`);
304
+ if (isExpired(record, now)) {
305
+ throw new Error(`mandate ${mandateId} expired at ${record.expiresAt}`);
306
+ }
307
+ if (amountMinor > remainingMinor(record)) {
308
+ throw new Error(`draw ${amountMinor} exceeds remaining budget ${remainingMinor(record)} (minor units)`);
309
+ }
310
+ if (!hasDrawCountHeadroom(record)) {
311
+ throw new Error(`mandate ${mandateId} reached its approved purchase-count limit`);
312
+ }
313
+ const reservationId = `rsv_${randomBytes(9).toString('base64url')}`;
314
+ record.reservations.push({ reservationId, amountMinor, reservedAt: now.toISOString() });
315
+ await this.save(file);
316
+ return reservationId;
317
+ });
318
+ }
319
+ /** Commit a reservation to permanent spend and record the payable draw. */
320
+ commit(mandateId, reservationId, meta, now = new Date()) {
321
+ return this.run(async () => {
322
+ const file = await this.load();
323
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
324
+ if (!record)
325
+ throw new Error(`no such mandate ${mandateId}`);
326
+ const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
327
+ if (idx === -1) {
328
+ throw new Error(`reservation ${reservationId} is not open (already committed or released)`);
329
+ }
330
+ const [reservation] = record.reservations.splice(idx, 1);
331
+ record.spentMinor += reservation.amountMinor;
332
+ record.draws.push({
333
+ drawId: `drw_${randomBytes(9).toString('base64url')}`,
334
+ reservationId,
335
+ amountMinor: reservation.amountMinor,
336
+ intentId: meta.intentId,
337
+ status: 'committed',
338
+ at: now.toISOString(),
339
+ });
340
+ await this.save(file);
341
+ return record;
342
+ });
343
+ }
344
+ /** Release a reservation back to availability (draw failed / not payable). */
345
+ release(mandateId, reservationId, now = new Date()) {
346
+ return this.run(async () => {
347
+ const file = await this.load();
348
+ const record = file.mandates.find((m) => m.mandateId === mandateId);
349
+ if (!record)
350
+ throw new Error(`no such mandate ${mandateId}`);
351
+ const idx = record.reservations.findIndex((r) => r.reservationId === reservationId);
352
+ if (idx === -1) {
353
+ // Idempotent by refusal: a committed/released reservation cannot be
354
+ // released again, but a redundant release must not corrupt state.
355
+ return record;
356
+ }
357
+ const [reservation] = record.reservations.splice(idx, 1);
358
+ record.draws.push({
359
+ drawId: `drw_${randomBytes(9).toString('base64url')}`,
360
+ reservationId,
361
+ amountMinor: reservation.amountMinor,
362
+ intentId: record.mandateId,
363
+ status: 'released',
364
+ at: now.toISOString(),
365
+ });
366
+ await this.save(file);
367
+ return record;
368
+ });
369
+ }
370
+ }
371
+ export function defaultLedgerPath() {
372
+ return (process.env.VISA_CARD_MANDATE_LEDGER_FILE ?? join(homedir(), '.visa-mcp', 'card-mandates.json'));
373
+ }
@@ -1,5 +1,5 @@
1
1
  import type { Page } from 'playwright-core';
2
- export type OutcomeStatus = 'confirmed' | 'declined' | 'action-required' | 'processing' | 'unknown';
2
+ export type OutcomeStatus = 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'processing' | 'unknown';
3
3
  export type OutcomeClassification = {
4
4
  status: OutcomeStatus;
5
5
  /** The named pattern that decided the status, e.g. "body:card-declined". */
@@ -8,7 +8,7 @@ export type OutcomeClassification = {
8
8
  /** Pure classification of one page state (case-insensitive on all inputs). */
9
9
  export declare function classifyOutcomePage(url: string, bodyText: string, frameUrls?: readonly string[]): OutcomeClassification;
10
10
  export type ObservedOutcome = {
11
- status: 'confirmed' | 'declined' | 'action-required' | 'unknown';
11
+ status: 'confirmed' | 'declined' | 'action-required' | 'verification-required' | 'unknown';
12
12
  signal: string | null;
13
13
  /** Last non-final classification when the deadline expired ('processing' | 'unknown'). */
14
14
  lastSeen: OutcomeStatus;
@@ -65,11 +65,34 @@ const ACTION_REQUIRED_URL = [
65
65
  // credential) but its copy also says "Enter the code sent to …", which would
66
66
  // otherwise match body:code-sent and misreport it as 3DS. Its own copy is
67
67
  // distinctive: "to use your saved information" / "Logging in as <email>".
68
- const ACTION_REQUIRED_BODY = [
68
+ // The Stripe Link login modal. Checked BEFORE every other body pattern
69
+ // (including the email-OTP split below): completing it pays with an old saved
70
+ // card, not the minted credential, so it must stay action-required (human) and
71
+ // must never be mistaken for an agent-resolvable email code. See #5879.
72
+ const LINK_WALLET_BODY = [
69
73
  {
70
74
  name: 'body:link-wallet',
71
75
  re: /use your saved information|logging in as .{1,60}?\. your device will be remembered/,
72
76
  },
77
+ ];
78
+ // Agent-RESOLVABLE email OTP: a code the MERCHANT emailed to the agent's own
79
+ // inbox, which wallet_mail_await_otp can retrieve. Kept deliberately narrow —
80
+ // it requires an EMAIL indicator (email / inbox / @) so an SMS "code sent to
81
+ // your phone" or an issuer 3DS challenge (both un-retrievable by the agent)
82
+ // stay action-required and fall to a human. This is the ONLY status the OTP
83
+ // subroutine will try to auto-complete.
84
+ const VERIFICATION_REQUIRED_BODY = [
85
+ // "a code was sent / e-mailed to your email / inbox" (code-first order).
86
+ {
87
+ name: 'body:email-code-sent',
88
+ re: /(?:code|passcode).{0,40}(?:sent|e-?mailed).{0,40}(?:e-?mail|inbox|@)/,
89
+ },
90
+ // "we e-mailed you a … code" (email-verb-first order).
91
+ { name: 'body:email-code-sent', re: /e-?mailed\b.{0,40}(?:code|passcode)/ },
92
+ // "check your email / inbox for a … code".
93
+ { name: 'body:check-email-code', re: /check your (?:e-?mail|inbox).{0,40}(?:code|verif)/ },
94
+ ];
95
+ const ACTION_REQUIRED_BODY = [
73
96
  { name: 'body:3d-secure', re: /3-?d secure/ },
74
97
  { name: 'body:verify-identity', re: /verify your identity/ },
75
98
  { name: 'body:one-time-code', re: /enter (?:the )?(?:one[- ]?time|verification|security) code/ },
@@ -122,6 +145,15 @@ export function classifyOutcomePage(url, bodyText, frameUrls = []) {
122
145
  if (frame)
123
146
  return { status: 'action-required', signal: frame };
124
147
  }
148
+ // Stripe Link modal first — it must never fall through to the email-OTP split.
149
+ const linkWallet = match(LINK_WALLET_BODY, b);
150
+ if (linkWallet)
151
+ return { status: 'action-required', signal: linkWallet };
152
+ // Agent-resolvable email OTP: its own verdict, so the executor can retrieve
153
+ // the code rather than stopping for a human.
154
+ const emailVerify = match(VERIFICATION_REQUIRED_BODY, b);
155
+ if (emailVerify)
156
+ return { status: 'verification-required', signal: emailVerify };
125
157
  const challengeUrl = match(ACTION_REQUIRED_URL, u);
126
158
  if (challengeUrl)
127
159
  return { status: 'action-required', signal: challengeUrl };
@@ -167,6 +199,9 @@ export async function observeOutcome(page, opts = {}) {
167
199
  // continues until the post-challenge confirmed/declined signal appears.
168
200
  if (seen.status === 'confirmed' ||
169
201
  seen.status === 'declined' ||
202
+ // Email OTP won't self-resolve either — return it so the executor's
203
+ // single-use OTP subroutine can retrieve the code (or a human is asked).
204
+ seen.status === 'verification-required' ||
170
205
  (seen.status === 'action-required' && !opts.holdThroughChallenge)) {
171
206
  return {
172
207
  status: seen.status,
@@ -8,3 +8,12 @@
8
8
  export declare function assertOwnerOnlyFile(path: string, label: string, maxBytes?: number): Promise<void>;
9
9
  /** The same guard, then parse — contents are read only after the guard passes. */
10
10
  export declare function readOwnerOnlyJson<T>(path: string, label: string, maxBytes?: number): Promise<T>;
11
+ /**
12
+ * Atomically write an owner-only (chmod 600) JSON artifact: serialize to a
13
+ * sibling temp file created 0600, then rename over the target so a reader never
14
+ * observes a half-written ledger (and a crash mid-write leaves the prior file
15
+ * intact). The parent directory is created if absent (matching ~/.visa-mcp).
16
+ * Used by the card-mandate ledger — the same owner-only posture as
17
+ * agent-credential.json / contact.json.
18
+ */
19
+ export declare function writeOwnerOnlyJson(path: string, value: unknown): Promise<void>;
@@ -1,4 +1,6 @@
1
- import { lstat, readFile } from 'node:fs/promises';
1
+ import { randomBytes } from 'node:crypto';
2
+ import { chmod, lstat, mkdir, readFile, rename, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
2
4
  /**
3
5
  * lstat-only guard for operator-supplied secret-bearing files: must be a
4
6
  * regular file (never a symlink), owner-only (chmod 600), and size-bounded.
@@ -20,3 +22,20 @@ export async function readOwnerOnlyJson(path, label, maxBytes = 16 * 1024) {
20
22
  await assertOwnerOnlyFile(path, label, maxBytes);
21
23
  return JSON.parse(await readFile(path, 'utf8'));
22
24
  }
25
+ /**
26
+ * Atomically write an owner-only (chmod 600) JSON artifact: serialize to a
27
+ * sibling temp file created 0600, then rename over the target so a reader never
28
+ * observes a half-written ledger (and a crash mid-write leaves the prior file
29
+ * intact). The parent directory is created if absent (matching ~/.visa-mcp).
30
+ * Used by the card-mandate ledger — the same owner-only posture as
31
+ * agent-credential.json / contact.json.
32
+ */
33
+ export async function writeOwnerOnlyJson(path, value) {
34
+ await mkdir(dirname(path), { recursive: true });
35
+ const tmp = `${path}.tmp-${randomBytes(6).toString('hex')}`;
36
+ await writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
37
+ // writeFile's mode only applies on create; chmod defensively in case the temp
38
+ // name somehow pre-existed with looser bits.
39
+ await chmod(tmp, 0o600);
40
+ await rename(tmp, path);
41
+ }
@@ -0,0 +1,8 @@
1
+ export declare function safeTraceHandle(value: unknown): string | undefined;
2
+ export declare function traceHandleFields(value: {
3
+ vgsTraceId?: unknown;
4
+ networkCorrelationId?: unknown;
5
+ }): {
6
+ vgsTraceId?: string;
7
+ networkCorrelationId?: string;
8
+ };
@@ -0,0 +1,12 @@
1
+ const SAFE_TRACE_HANDLE_RE = /^[A-Za-z0-9._:-]{1,128}$/;
2
+ export function safeTraceHandle(value) {
3
+ return typeof value === 'string' && SAFE_TRACE_HANDLE_RE.test(value) ? value : undefined;
4
+ }
5
+ export function traceHandleFields(value) {
6
+ const vgsTraceId = safeTraceHandle(value.vgsTraceId);
7
+ const networkCorrelationId = safeTraceHandle(value.networkCorrelationId);
8
+ return {
9
+ ...(vgsTraceId ? { vgsTraceId } : {}),
10
+ ...(networkCorrelationId ? { networkCorrelationId } : {}),
11
+ };
12
+ }
@@ -1,5 +1,4 @@
1
- export type Contact = {
2
- email?: string;
1
+ export type PostalAddress = {
3
2
  firstName?: string;
4
3
  lastName?: string;
5
4
  fullName?: string;
@@ -10,6 +9,11 @@ export type Contact = {
10
9
  postalCode?: string;
11
10
  country?: string;
12
11
  };
12
+ export type Contact = PostalAddress & {
13
+ email?: string;
14
+ phone?: string;
15
+ billingAddress?: PostalAddress;
16
+ };
13
17
  export type FilledField = {
14
18
  role: string;
15
19
  locator: string;
@@ -23,4 +27,18 @@ export type FilledField = {
23
27
  export type FillResult = {
24
28
  ok: boolean;
25
29
  filled: FilledField[];
30
+ detail?: string;
31
+ };
32
+ export type OtpRequest = {
33
+ /** ISO watermark captured BEFORE the click that triggers the OTP email. */
34
+ after: string;
35
+ /** The merchant hostname; the sender's registrable domain must match it. */
36
+ merchantHost: string;
37
+ };
38
+ export type OtpResolution = {
39
+ /** The single-use code. The executor fills it exactly once; never retried. */
40
+ code: string;
41
+ /** The sender's registrable domain — a trust signal, safe to log (no PII). */
42
+ fromDomain: string;
26
43
  };
44
+ export type OtpResolver = (req: OtpRequest) => Promise<OtpResolution | null>;
@@ -0,0 +1,82 @@
1
+ import type { VgsCheckoutTarget, VgsPaymentCredential } from '../vgs-live-instrument.js';
2
+ export type ServerMintDeps = {
3
+ /** Injectable for tests — defaults to global fetch. */
4
+ fetchImpl?: typeof fetch;
5
+ /** Injectable for tests — never wall-clock-sleep in a unit test. */
6
+ sleep?: (ms: number) => Promise<void>;
7
+ env?: NodeJS.ProcessEnv;
8
+ };
9
+ /**
10
+ * The merchant ORIGIN (scheme + host) — not the full checkout URL. A merchant's
11
+ * identity is its origin; a real checkout URL can carry hundreds of chars of
12
+ * campaign/tracking query params. Confirmed live: an oversized `merchantUrl`
13
+ * (a ~280-char Wikimedia donation URL) makes the cryptogram mint fail downstream
14
+ * with a vague `502 "card network could not complete"`, while the same merchant
15
+ * with a trimmed URL mints fine. Sending the origin is both correct (that IS the
16
+ * merchant) and safely bounded. Falls back to the raw value if it does not parse
17
+ * — the mint must never throw here.
18
+ */
19
+ export declare function merchantOrigin(url: string): string;
20
+ /**
21
+ * Optional mandate override for serverCreateIntent. Absent → the historical
22
+ * 1:1 fresh-tap shape is preserved byte-for-byte (cap = ceil(amount)+10 min 25,
23
+ * quantity 1, 30-day window). Present → the card-mandate (budget) layer sets a
24
+ * CEILING decline threshold and a multi-draw quantity so one passkey approves a
25
+ * spend ceiling and later draws pull cryptograms under it without a fresh tap.
26
+ * Whether the network honors a ceiling-scoped assurance across multiple
27
+ * sub-amount draws is UNPROVEN — see packages/checkout-engine/src/mandate/.
28
+ */
29
+ export type ServerIntentMandateOverride = {
30
+ /** Wire decline-threshold amount (major-unit decimal string), e.g. "500.00". */
31
+ declineThresholdAmount?: string;
32
+ /** Number of draws the intent may fulfil (VGS mandate `quantity`). */
33
+ quantity?: number;
34
+ /** ISO 8601 mandate validity end. */
35
+ effectiveUntil?: string;
36
+ /** Human-readable consumer prompt describing the ceiling grant. */
37
+ consumerPrompt?: string;
38
+ };
39
+ /**
40
+ * Create a fresh intent via POST {base}/api/vgs/intent. The server holds the VGS
41
+ * credential and calls the gateway; we send the same mandate shape the local
42
+ * path built (cap = ceil(amount)+10, min 25; merchant category Retail/5999) —
43
+ * unless `input.mandate` overrides the threshold/quantity/window for the
44
+ * card-mandate (budget) layer.
45
+ */
46
+ export declare function serverCreateIntent(base: string, mintToken: string, input: {
47
+ tokenId: string;
48
+ assuranceData: unknown;
49
+ transaction: VgsCheckoutTarget;
50
+ mandate?: ServerIntentMandateOverride;
51
+ }, deps?: ServerMintDeps): Promise<{
52
+ intentId: string;
53
+ status: string | null;
54
+ }>;
55
+ /**
56
+ * Mint the FULL payment credential via POST {base}/api/vgs/payment-cryptogram.
57
+ * The server's route does a SINGLE gateway call and 502s on a not-COMPLETED
58
+ * (e.g. PENDING) cryptogram, but live intent approval is asynchronous and the
59
+ * first cryptogram answer can be PENDING (#5709). So we retry the route on any
60
+ * non-2xx up to PENDING_ATTEMPTS, matching fetch-credential.mjs's cadence
61
+ * exactly — a genuinely hard failure just surfaces after the same bounded wait.
62
+ * Re-POSTing for the same intentId is idempotent (mirrors the old client loop).
63
+ */
64
+ export declare function serverFetchCryptogram(base: string, mintToken: string, input: {
65
+ tokenId: string;
66
+ intentId: string;
67
+ transaction: VgsCheckoutTarget;
68
+ }, deps?: ServerMintDeps): Promise<VgsPaymentCredential>;
69
+ /** Report the observed merchant outcome via POST {base}/api/vgs/confirmation. */
70
+ export declare function serverPostConfirmation(base: string, mintToken: string, input: {
71
+ tokenId: string;
72
+ intentId: string;
73
+ transactionStatus: string;
74
+ transactionType: string;
75
+ transactionTimestamp: string;
76
+ transaction: {
77
+ transactionAmount: string;
78
+ transactionCurrencyCode: string;
79
+ };
80
+ }, deps?: ServerMintDeps): Promise<{
81
+ ok: true;
82
+ }>;