@visa/cli 4.1.0-rc.229 → 4.1.0-rc.230

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 CHANGED
@@ -76,7 +76,7 @@ Shopify checkout skill, and both Visa and Shopify UCP MCP entries in one step:
76
76
  visa agent skill --commerce --runtime codex
77
77
  # runtimes with writable MCP config: codex, openclaw, hermes
78
78
 
79
- # Before the first canary, also verify the pinned local UCP profile:
79
+ # Inspect the installed local bundle without changing skills or MCP config:
80
80
  visa agent skill --commerce --runtime codex --check
81
81
  ```
82
82
 
@@ -87,13 +87,20 @@ reports both MCP entries as mounted or already mounted; restart or reload the
87
87
  agent before using the new tools. The installer never performs discovery or a
88
88
  checkout itself. The install-only command exits successfully after writing the
89
89
  bundle, but reports `commerceReady: false` / `ucp_check_required` until the
90
- explicit `--check` gate passes.
91
-
92
- Checked readiness runs the pinned `ucp doctor --skip-network` diagnostic and
93
- reports the exact profile initialization command if local state is missing. It
94
- also refuses to claim natural-language shopping readiness when a top-level
95
- `ucp` or `shop` skill with a `SKILL.md` is present in the same runtime directory. The
96
- installer reports those paths but never deletes or disables user-owned skills.
90
+ explicit `--check` gate passes. `commerceReady` is a compatibility alias for
91
+ local bundle readiness only; it never proves that a merchant purchase can be
92
+ completed. The JSON result keeps `purchase.ready: false` until a separate live
93
+ checkout preflight is performed.
94
+
95
+ Checked readiness is read-only: missing or modified skills and missing,
96
+ conflicting, or malformed MCP entries are reported without installing or
97
+ rewriting anything. It parses the active local profile and its pinned Shopify
98
+ profile contract directly from disk, without starting `npx`, and reports the
99
+ exact profile initialization command if local state is missing or invalid. It
100
+ also refuses to claim local natural-language shopping readiness when a
101
+ top-level `ucp` or `shop` skill with a `SKILL.md` is present in the same runtime
102
+ directory. The installer reports those paths but never deletes or disables
103
+ user-owned skills.
97
104
 
98
105
  If the selected runtime already has a non-equivalent `shopify-ucp` entry, the
99
106
  command fails without changing that entry or its environment, authentication,
@@ -234,7 +241,8 @@ is required so the CLI never guesses which agent can spend.
234
241
  | `setup_status` | Read the server's one `nextAction` for a setup operation, plus the cadence to poll it at |
235
242
  | `setup_resume` | Put the review link and compare code back in front of the human after a restart or a closed tab |
236
243
  | `setup_cancel` | Abandon a setup still waiting on the human; never undoes an approval |
237
- | `agent_connect` / `agent_connect_poll` | Initiate an owner-approved spending grant, then poll it to activation |
244
+ | `agent_connect` | Initiate an owner-approved spending grant for a paired agent |
245
+ | `agent_connect_poll` | Poll that grant ceremony to activation (returns caps, and the funding address for wallet) |
238
246
  | `wallet_status` | Delegated wallet address, network, and policy state |
239
247
  | `wallet_policy_set` | Set per-transaction / daily caps (with human approval) |
240
248
  | `wallet_discover` | Sweep x402 directories for services, with live re-probing |
@@ -17,9 +17,34 @@ export declare function parseShopifySummary(text: string): ShopifySummary;
17
17
  export declare function readShopifyAmount(page: Page, requireVerified: boolean): Promise<PageAmountRead>;
18
18
  export declare function readStableShopifyAmount(page: Page, timeoutMs?: number): Promise<PageAmountRead>;
19
19
  export declare function isShopifyCheckoutPage(page: Page): Promise<boolean>;
20
- export declare function detectShopifyChallenge(page: Page): Promise<{
20
+ export type ShopifyCheckoutSurface = {
21
21
  signal: string;
22
- } | null>;
22
+ subtype: 'recognition' | 'authentication' | 'wallet';
23
+ };
24
+ export type ShopifyGuestCheckoutResult = {
25
+ status: 'guest-ready';
26
+ signal: 'guest-card-form';
27
+ } | {
28
+ status: 'transitioned';
29
+ signal: string;
30
+ } | {
31
+ status: 'action-required';
32
+ signal: string;
33
+ detail: string;
34
+ };
35
+ export declare function detectShopifyCheckoutSurface(page: Page): Promise<ShopifyCheckoutSurface | null>;
36
+ /**
37
+ * Resolve Shopify's reversible Shop-wallet takeover before review. The helper
38
+ * only clicks an exact guest action or a scoped accessible close control inside
39
+ * a positively identified Shop surface. It never solves authentication, uses a
40
+ * Shop-saved payment method, force-clicks, or mutates the DOM.
41
+ */
42
+ export declare function ensureShopifyGuestCheckout(page: Page, options?: {
43
+ timeoutMs?: number;
44
+ stableMs?: number;
45
+ }): Promise<ShopifyGuestCheckoutResult>;
46
+ /** Read-only assertion for the irreversible credential boundary. */
47
+ export declare function assertShopifyGuestCheckout(page: Page): Promise<ShopifyGuestCheckoutResult>;
23
48
  /**
24
49
  * The en-US variant of a localized Shopify checkout URL, or null when it is
25
50
  * already English (or not locale-suffixed). Shopify renders the checkout in
@@ -122,41 +122,215 @@ export async function isShopifyCheckoutPage(page) {
122
122
  })
123
123
  .catch(() => false);
124
124
  }
125
- export async function detectShopifyChallenge(page) {
126
- const visibleDialog = await page
127
- .evaluate(() => Array.from(document.querySelectorAll('dialog, [role="dialog"], [aria-modal="true"]')).some((element) => {
128
- const style = getComputedStyle(element);
129
- if (element.offsetParent === null ||
130
- style.display === 'none' ||
131
- style.visibility === 'hidden') {
132
- return false;
133
- }
134
- const text = element.innerText ?? '';
135
- return (/\bshop pay\b/i.test(text) &&
136
- /enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent/i.test(text));
137
- }))
138
- .catch(() => false);
139
- if (visibleDialog)
140
- return { signal: 'body:shop-pay-code' };
125
+ const SHOP_SURFACE_SELECTOR = [
126
+ 'dialog',
127
+ '[role="dialog"]',
128
+ '[aria-modal="true"]',
129
+ '[data-shop-pay-modal]',
130
+ '[data-shopify-pay-modal]',
131
+ ].join(', ');
132
+ const SHOP_CODE_TEXT = /enter (?:the )?(?:verification|one[- ]?time)?\s*code|code (?:was |has been )?sent|verification code/i;
133
+ const SHOP_AUTH_TEXT = /sign in|log in|confirm (?:it(?:'|’)s )?you|continue with shop|shop account/i;
134
+ const SHOP_RECOGNITION_TEXT = /recognized|faster checkout|remembered|saved (?:payment|details)/i;
135
+ async function classifyBodyShopSurface(page) {
136
+ const roots = page.locator(SHOP_SURFACE_SELECTOR);
137
+ for (let index = 0; index < (await roots.count().catch(() => 0)); index += 1) {
138
+ const root = roots.nth(index);
139
+ if (!(await root.isVisible().catch(() => false)))
140
+ continue;
141
+ const [text, attrs, shopMark] = await Promise.all([
142
+ root.innerText().catch(() => ''),
143
+ Promise.all(['id', 'class', 'aria-label', 'data-testid'].map((name) => root.getAttribute(name).catch(() => ''))).then((values) => values.join(' ')),
144
+ root
145
+ .locator('img[alt="Shop" i], img[alt="Shop Pay" i], [aria-label="Shop" i]')
146
+ .count()
147
+ .then((count) => count > 0)
148
+ .catch(() => false),
149
+ ]);
150
+ // A generic dialog, generic Close button, or marketing use of "shop" is
151
+ // not enough. Positive identification requires Shop Pay copy/attributes,
152
+ // or a Shop mark paired with authentication/guest-checkout language.
153
+ const positiveShopSignal = /\bshop\s*pay\b/i.test(`${text} ${attrs}`) ||
154
+ /shop[-_ ]?(?:pay|ify-pay)/i.test(attrs) ||
155
+ (shopMark && (SHOP_AUTH_TEXT.test(text) || /checkout as guest/i.test(text)));
156
+ if (!positiveShopSignal)
157
+ continue;
158
+ const subtype = SHOP_CODE_TEXT.test(text)
159
+ ? 'authentication'
160
+ : /checkout as guest/i.test(text)
161
+ ? 'wallet'
162
+ : SHOP_AUTH_TEXT.test(text)
163
+ ? 'authentication'
164
+ : SHOP_RECOGNITION_TEXT.test(text)
165
+ ? 'recognition'
166
+ : 'wallet';
167
+ return {
168
+ root,
169
+ subtype,
170
+ signal: `body:shop-${subtype}`,
171
+ };
172
+ }
173
+ return null;
174
+ }
175
+ async function classifyFrameShopSurface(page) {
141
176
  const frames = page.locator('iframe');
142
177
  for (let index = 0; index < (await frames.count().catch(() => 0)); index += 1) {
143
178
  const frame = frames.nth(index);
144
179
  if (!(await frame.isVisible().catch(() => false)))
145
180
  continue;
146
181
  const src = (await frame.getAttribute('src').catch(() => '')) ?? '';
147
- if (!/shop\.app\/accounts\/login/i.test(src))
182
+ if (!/^https:\/\/(?:[^/]+\.)?shop\.app\/(?:accounts\/login|pay|checkout)\b/i.test(src))
148
183
  continue;
149
184
  const accessibleName = [
150
185
  await frame.getAttribute('title').catch(() => ''),
151
186
  await frame.getAttribute('aria-label').catch(() => ''),
152
187
  await frame.getAttribute('name').catch(() => ''),
153
188
  ].join(' ');
154
- if (/verification|one[- ]?time|code/i.test(accessibleName)) {
155
- return { signal: 'frame:shop-pay-code' };
156
- }
189
+ const subtype = SHOP_CODE_TEXT.test(accessibleName) ? 'authentication' : 'wallet';
190
+ return { frame, subtype, signal: `frame:shop-${subtype}` };
157
191
  }
158
192
  return null;
159
193
  }
194
+ export async function detectShopifyCheckoutSurface(page) {
195
+ const located = (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
196
+ return located ? { signal: located.signal, subtype: located.subtype } : null;
197
+ }
198
+ async function locateShopifyCheckoutSurface(page) {
199
+ return (await classifyBodyShopSurface(page)) ?? (await classifyFrameShopSurface(page));
200
+ }
201
+ async function visibleGuestCardForm(page) {
202
+ const detected = await detectFields(page).catch(() => null);
203
+ return Boolean(detected?.fields.number?.visible);
204
+ }
205
+ /**
206
+ * Resolve Shopify's reversible Shop-wallet takeover before review. The helper
207
+ * only clicks an exact guest action or a scoped accessible close control inside
208
+ * a positively identified Shop surface. It never solves authentication, uses a
209
+ * Shop-saved payment method, force-clicks, or mutates the DOM.
210
+ */
211
+ export async function ensureShopifyGuestCheckout(page, options = {}) {
212
+ const timeoutMs = options.timeoutMs ?? 1_800;
213
+ const stableMs = options.stableMs ?? 650;
214
+ const startedAt = Date.now();
215
+ let guestStableSince = null;
216
+ let located = null;
217
+ while (Date.now() - startedAt < timeoutMs) {
218
+ located = await locateShopifyCheckoutSurface(page);
219
+ if (located)
220
+ break;
221
+ if (await visibleGuestCardForm(page)) {
222
+ guestStableSince ??= Date.now();
223
+ if (Date.now() - guestStableSince >= stableMs) {
224
+ return { status: 'guest-ready', signal: 'guest-card-form' };
225
+ }
226
+ }
227
+ else {
228
+ guestStableSince = null;
229
+ }
230
+ await page.waitForTimeout(75);
231
+ }
232
+ if (!located) {
233
+ return (await visibleGuestCardForm(page))
234
+ ? { status: 'guest-ready', signal: 'guest-card-form' }
235
+ : {
236
+ status: 'action-required',
237
+ signal: 'shopify:guest-card-form-unavailable',
238
+ detail: 'Shopify guest card form did not become stable before review; no payment credential was requested',
239
+ };
240
+ }
241
+ const originBefore = new URL(page.url()).origin;
242
+ let action = null;
243
+ let actionSignal = '';
244
+ if (located.root) {
245
+ const exactGuest = located.root
246
+ .getByRole('button', { name: /^checkout as guest$/i })
247
+ .or(located.root.getByRole('link', { name: /^checkout as guest$/i }))
248
+ .first();
249
+ if ((await exactGuest.count().catch(() => 0)) > 0 &&
250
+ (await exactGuest.isVisible().catch(() => false))) {
251
+ action = exactGuest;
252
+ actionSignal = `${located.signal}:checkout-as-guest`;
253
+ }
254
+ else if (located.subtype !== 'authentication' ||
255
+ !(await located.root.innerText()).match(SHOP_CODE_TEXT)) {
256
+ const scopedClose = located.root
257
+ .getByRole('button', { name: /^close(?: shop(?: pay)?)?$/i })
258
+ .first();
259
+ if ((await scopedClose.count().catch(() => 0)) > 0 &&
260
+ (await scopedClose.isVisible().catch(() => false))) {
261
+ action = scopedClose;
262
+ actionSignal = `${located.signal}:scoped-close`;
263
+ }
264
+ }
265
+ }
266
+ else if (located.frame) {
267
+ const frame = located.frame.contentFrame();
268
+ const exactGuest = frame.getByRole('button', { name: /^checkout as guest$/i }).first();
269
+ if ((await exactGuest.count().catch(() => 0)) > 0 &&
270
+ (await exactGuest.isVisible().catch(() => false))) {
271
+ action = exactGuest;
272
+ actionSignal = `${located.signal}:checkout-as-guest`;
273
+ }
274
+ }
275
+ if (!action) {
276
+ return {
277
+ status: 'action-required',
278
+ signal: located.signal,
279
+ detail: `Shopify Shop surface could not be safely exited (${located.signal}); no payment credential was requested`,
280
+ };
281
+ }
282
+ await action.click();
283
+ const surfaceGone = await page
284
+ .waitForFunction((selector) => !Array.from(document.querySelectorAll(selector)).some((element) => {
285
+ const style = getComputedStyle(element);
286
+ const text = element.innerText ?? '';
287
+ const attrs = [
288
+ element.id,
289
+ element.className,
290
+ element.getAttribute('aria-label'),
291
+ element.getAttribute('data-testid'),
292
+ ].join(' ');
293
+ const visible = element.offsetParent !== null &&
294
+ style.display !== 'none' &&
295
+ style.visibility !== 'hidden';
296
+ return visible && /\bshop\s*pay\b|shop[-_ ]?(?:pay|ify-pay)/i.test(`${text} ${attrs}`);
297
+ }), SHOP_SURFACE_SELECTOR, { timeout: 2_000 })
298
+ .then(() => true)
299
+ .catch(() => false);
300
+ const sameOrigin = new URL(page.url()).origin === originBefore;
301
+ const surfaceRemains = Boolean(await locateShopifyCheckoutSurface(page));
302
+ if (!surfaceGone || surfaceRemains || !sameOrigin || !(await visibleGuestCardForm(page))) {
303
+ const signal = !sameOrigin
304
+ ? 'shopify:guest-transition-origin-changed'
305
+ : !surfaceGone || surfaceRemains
306
+ ? 'shopify:shop-surface-remained'
307
+ : 'shopify:guest-card-form-unavailable';
308
+ return {
309
+ status: 'action-required',
310
+ signal,
311
+ detail: `Shopify guest transition could not be verified (${signal}); no payment credential was requested`,
312
+ };
313
+ }
314
+ return { status: 'transitioned', signal: actionSignal };
315
+ }
316
+ /** Read-only assertion for the irreversible credential boundary. */
317
+ export async function assertShopifyGuestCheckout(page) {
318
+ const surface = await locateShopifyCheckoutSurface(page);
319
+ if (surface) {
320
+ return {
321
+ status: 'action-required',
322
+ signal: surface.signal,
323
+ detail: `Shopify Shop surface reappeared after review (${surface.signal}); no payment credential was requested`,
324
+ };
325
+ }
326
+ return (await visibleGuestCardForm(page))
327
+ ? { status: 'guest-ready', signal: 'guest-card-form' }
328
+ : {
329
+ status: 'action-required',
330
+ signal: 'shopify:guest-card-form-unavailable',
331
+ detail: 'Shopify guest card form is no longer available after review; no payment credential was requested',
332
+ };
333
+ }
160
334
  /**
161
335
  * The en-US variant of a localized Shopify checkout URL, or null when it is
162
336
  * already English (or not locale-suffixed). Shopify renders the checkout in
@@ -1,5 +1,5 @@
1
1
  import { type Browser } from 'playwright-core';
2
- import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutOutcome, type CheckoutFailureCode, type CheckoutResult, type PreparedCheckoutSessionStore } from './executor.js';
2
+ import { prepareCheckout as realPrepareCheckout, submitApprovedCheckout as realSubmitApprovedCheckout, type CheckoutMode, type CheckoutOutcome, type CheckoutFailureCode, type CheckoutResult, type PreparedCheckoutSessionStore } from './executor.js';
3
3
  import { claimMandatePickup as realClaimMandatePickup, runHostedApproval as realRunHostedApproval } from './hosted-approval.js';
4
4
  import { type VgsCheckoutTarget } from './vgs-live-instrument.js';
5
5
  import { serverFetchCryptogram, serverPostConfirmation } from './vgs-gateway/server-mint-client.js';
@@ -23,10 +23,12 @@ export declare function isTransientDrawFailure(err: unknown): boolean;
23
23
  */
24
24
  export declare function classifyCardDrawVerdictFailure(err: unknown): {
25
25
  transient: boolean;
26
+ grantCapacity: boolean;
26
27
  reasons: string[];
27
28
  } | null;
28
29
  export type CliReviewInput = {
29
30
  url: string;
31
+ checkoutRoute: 'guest-card';
30
32
  amount: string;
31
33
  currency: string;
32
34
  credentialPath: string;
@@ -42,6 +44,12 @@ export type CliReviewInput = {
42
44
  approvalBaseUrl: string;
43
45
  merchantName?: string;
44
46
  merchantCountryCode?: string;
47
+ trustedMerchantIdentity?: Readonly<{
48
+ handoffId: string;
49
+ checkoutId: string;
50
+ allowedOrigins: readonly string[];
51
+ expiresAt: string;
52
+ }>;
45
53
  };
46
54
  export type CliReviewFacts = {
47
55
  reviewId: string;
@@ -63,8 +71,9 @@ export declare class CheckoutReviewRefusedError extends Error {
63
71
  readonly failureCode?: CheckoutFailureCode;
64
72
  readonly requiresAdapter: string[];
65
73
  readonly detectedRoles: string[];
74
+ readonly receiptWrite: ReceiptWriteObservation;
66
75
  readonly detail?: string;
67
- constructor(result: CheckoutResult);
76
+ constructor(result: CheckoutResult, receiptWrite: ReceiptWriteObservation);
68
77
  }
69
78
  export type CliPayInput = CliReviewInput & {
70
79
  reviewId: string;
@@ -102,6 +111,8 @@ export type CliReceiptFacts = {
102
111
  credentialIssued: boolean;
103
112
  /** True only when at least one payment field was successfully filled. */
104
113
  credentialDisclosed: boolean;
114
+ /** Privacy-safe local receipt persistence outcome for centralized correlation. */
115
+ receiptWrite?: ReceiptWriteObservation;
105
116
  };
106
117
  type PayAttempt = {
107
118
  fingerprint: string;
@@ -180,6 +191,9 @@ export type CliMandateFacts = CardMandateFacts & {
180
191
  };
181
192
  type Session = {
182
193
  browser: Browser;
194
+ /** Exact caller URL repeated at pay time; may contain a UCP capability. */
195
+ requestUrl: string;
196
+ checkoutRoute: 'guest-card';
183
197
  target: VgsCheckoutTarget;
184
198
  amountMinor: number;
185
199
  currency: string;
@@ -260,6 +274,14 @@ export type CliEngineDeps = {
260
274
  onApprovalUrl?: (url: string) => void;
261
275
  reportVicOutcome?: typeof realReportVicOutcome;
262
276
  writeReceipt?: typeof realWriteReceipt;
277
+ /**
278
+ * Privacy-safe receipt persistence lifecycle signal. The event deliberately
279
+ * excludes the receipt body, checkout URL, filesystem path, and raw error.
280
+ * Observer failures are swallowed so logging can never alter checkout state.
281
+ */
282
+ onReceiptWrite?: (event: ReceiptWriteObservation) => void | Promise<void>;
283
+ /** Injectable privacy-safe id source for terminal observations before reviewId exists. */
284
+ createObservationId?: () => string;
263
285
  store?: PreparedCheckoutSessionStore;
264
286
  sessions?: Map<string, Session>;
265
287
  /** Exact-review singleflight registry; tests inject a fresh map for isolation. */
@@ -295,6 +317,17 @@ export type CliEngineDeps = {
295
317
  */
296
318
  resolveEmailOtp?: OtpResolver;
297
319
  };
320
+ export type ReceiptWriteObservation = {
321
+ event: 'checkout_receipt_write';
322
+ status: 'written' | 'failed';
323
+ mode: CheckoutMode;
324
+ checkoutOutcome: CheckoutOutcome;
325
+ merchantHost: string;
326
+ observationId: string;
327
+ failureCode: string | null;
328
+ errorCode: string | null;
329
+ panRedactions: number | null;
330
+ };
298
331
  export declare function createCliCheckoutEngine(deps?: CliEngineDeps): {
299
332
  startCardMandate(input: CliStartMandateInput): Promise<CliMandateFacts>;
300
333
  claimCardMandate(input: CliClaimMandateInput): Promise<CliMandateFacts>;