@visa/cli 4.1.0-rc.242 → 4.1.0-rc.243
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/executor.js +21 -1
- package/dist/checkout-engine/shopify-primary-domain.d.ts +25 -0
- package/dist/checkout-engine/shopify-primary-domain.js +96 -0
- package/dist/cli.js +10 -10
- package/dist/mcp-server/index.js +8 -8
- package/native/bin/win32-x64/visa-keychain-win.exe +0 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -27,6 +27,7 @@ import { traceHandleFields } from './trace-handles.js';
|
|
|
27
27
|
import { readGenericPageAmount } from './amount.js';
|
|
28
28
|
import { webBotAuthHeadersOrNone } from './web-bot-auth.js';
|
|
29
29
|
import { assertShopifyGuestCheckout, ensureShopifyGuestCheckout, isShopifyCheckoutPage, readShopifyAmount, readStableShopifyAmount, shopifyEnglishCheckoutUrl, } from './adapters/shopify.js';
|
|
30
|
+
import { navigationRedirectEvidence, shopifyPrimaryDomainAlias } from './shopify-primary-domain.js';
|
|
30
31
|
export { minorFromDecimal, pageCurrency } from './amount.js';
|
|
31
32
|
const SUBMIT_TEXT = /pay|place order|complete|buy|submit|checkout/i;
|
|
32
33
|
const REVEAL_TEXT = /continue|next|proceed|review|go to payment/i;
|
|
@@ -805,7 +806,7 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
805
806
|
evidence.step('navigation', {
|
|
806
807
|
url: options.trustedMerchantIdentity ? exactOrigin(options.url) : options.url,
|
|
807
808
|
});
|
|
808
|
-
await page.goto(options.url, { waitUntil: 'domcontentloaded' });
|
|
809
|
+
const navigationResponse = await page.goto(options.url, { waitUntil: 'domcontentloaded' });
|
|
809
810
|
await waitForStableDom(page);
|
|
810
811
|
evidence.step('dom-stable', {
|
|
811
812
|
url: options.trustedMerchantIdentity ? exactOrigin(page.url()) : page.url(),
|
|
@@ -824,6 +825,25 @@ export async function prepareCheckout(opts, store = defaultPreparedCheckoutStore
|
|
|
824
825
|
result: makeResult('blocked-by-mandate', fields, evidence, requiresAdapter, initialOriginRefusal),
|
|
825
826
|
};
|
|
826
827
|
}
|
|
828
|
+
if (!options.trustedMerchantIdentity) {
|
|
829
|
+
const shopifyAlias = shopifyPrimaryDomainAlias({
|
|
830
|
+
mandateHost: options.mandate.merchantHost,
|
|
831
|
+
initialUrl: options.url,
|
|
832
|
+
finalUrl: page.url(),
|
|
833
|
+
redirects: await navigationRedirectEvidence(navigationResponse),
|
|
834
|
+
});
|
|
835
|
+
if (shopifyAlias) {
|
|
836
|
+
evidence.step('note', {
|
|
837
|
+
kind: 'shopify-primary-domain-alias',
|
|
838
|
+
mandateHost: options.mandate.merchantHost,
|
|
839
|
+
checkoutHost: shopifyAlias,
|
|
840
|
+
});
|
|
841
|
+
// The proof is collected before any credential is minted. Bind this
|
|
842
|
+
// prepared session to Shopify's primary storefront host so every later
|
|
843
|
+
// approval and pre-submit revalidation stays strict on that host.
|
|
844
|
+
options.mandate = { ...options.mandate, merchantHost: shopifyAlias };
|
|
845
|
+
}
|
|
846
|
+
}
|
|
827
847
|
let reviewedOrigin;
|
|
828
848
|
const preFill = checkMandatePreFill(mandateForPage(options, page.url()), {
|
|
829
849
|
merchantHost,
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Response } from 'playwright-core';
|
|
2
|
+
export type NavigationRedirectEvidence = {
|
|
3
|
+
sourceUrl: string;
|
|
4
|
+
status: number;
|
|
5
|
+
location: string | null;
|
|
6
|
+
redirectReason: string | null;
|
|
7
|
+
poweredBy: string | null;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Resolve Shopify's permanent `*.myshopify.com` identity to its configured
|
|
11
|
+
* primary storefront host using evidence from the navigation redirect chain.
|
|
12
|
+
*
|
|
13
|
+
* This deliberately does not treat a generic redirect, matching HTML, DNS, or
|
|
14
|
+
* a shared Shopify CDN as merchant ownership proof. The only accepted cross-host
|
|
15
|
+
* hop must originate on the mandate-bound `myshopify.com` host over HTTPS and
|
|
16
|
+
* carry Shopify's exact `primary_domain_redirection` response signal directly
|
|
17
|
+
* to the final checkout host.
|
|
18
|
+
*/
|
|
19
|
+
export declare function shopifyPrimaryDomainAlias(args: {
|
|
20
|
+
mandateHost: string | undefined;
|
|
21
|
+
initialUrl: string;
|
|
22
|
+
finalUrl: string;
|
|
23
|
+
redirects: readonly NavigationRedirectEvidence[];
|
|
24
|
+
}): string | null;
|
|
25
|
+
export declare function navigationRedirectEvidence(finalResponse: Response | null): Promise<NavigationRedirectEvidence[]>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const MYSHOPIFY_HOST = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.myshopify\.com$/;
|
|
2
|
+
function hostname(value) {
|
|
3
|
+
try {
|
|
4
|
+
return new URL(value).hostname.toLowerCase();
|
|
5
|
+
}
|
|
6
|
+
catch {
|
|
7
|
+
try {
|
|
8
|
+
return new URL(`https://${value}`).hostname.toLowerCase();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function redirectTargetHost(redirect) {
|
|
16
|
+
if (!redirect.location)
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
return new URL(redirect.location, redirect.sourceUrl).hostname.toLowerCase();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Resolve Shopify's permanent `*.myshopify.com` identity to its configured
|
|
27
|
+
* primary storefront host using evidence from the navigation redirect chain.
|
|
28
|
+
*
|
|
29
|
+
* This deliberately does not treat a generic redirect, matching HTML, DNS, or
|
|
30
|
+
* a shared Shopify CDN as merchant ownership proof. The only accepted cross-host
|
|
31
|
+
* hop must originate on the mandate-bound `myshopify.com` host over HTTPS and
|
|
32
|
+
* carry Shopify's exact `primary_domain_redirection` response signal directly
|
|
33
|
+
* to the final checkout host.
|
|
34
|
+
*/
|
|
35
|
+
export function shopifyPrimaryDomainAlias(args) {
|
|
36
|
+
if (!args.mandateHost)
|
|
37
|
+
return null;
|
|
38
|
+
let initial;
|
|
39
|
+
let final;
|
|
40
|
+
try {
|
|
41
|
+
initial = new URL(args.initialUrl);
|
|
42
|
+
final = new URL(args.finalUrl);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (initial.protocol !== 'https:' || final.protocol !== 'https:')
|
|
48
|
+
return null;
|
|
49
|
+
const mandateHost = hostname(args.mandateHost);
|
|
50
|
+
const initialHost = initial.hostname.toLowerCase();
|
|
51
|
+
const finalHost = final.hostname.toLowerCase();
|
|
52
|
+
if (!mandateHost || mandateHost !== initialHost || !MYSHOPIFY_HOST.test(initialHost))
|
|
53
|
+
return null;
|
|
54
|
+
if (initialHost === finalHost)
|
|
55
|
+
return null;
|
|
56
|
+
const crossHostRedirects = args.redirects.filter((redirect) => {
|
|
57
|
+
const sourceHost = hostname(redirect.sourceUrl);
|
|
58
|
+
const targetHost = redirectTargetHost(redirect);
|
|
59
|
+
return sourceHost !== null && targetHost !== null && sourceHost !== targetHost;
|
|
60
|
+
});
|
|
61
|
+
if (crossHostRedirects.length !== 1)
|
|
62
|
+
return null;
|
|
63
|
+
const redirect = crossHostRedirects[0];
|
|
64
|
+
const source = new URL(redirect.sourceUrl);
|
|
65
|
+
const target = redirect.location ? new URL(redirect.location, source) : null;
|
|
66
|
+
if (source.protocol !== 'https:' ||
|
|
67
|
+
source.hostname.toLowerCase() !== initialHost ||
|
|
68
|
+
!target ||
|
|
69
|
+
target.protocol !== 'https:' ||
|
|
70
|
+
target.hostname.toLowerCase() !== finalHost ||
|
|
71
|
+
![301, 302, 307, 308].includes(redirect.status) ||
|
|
72
|
+
redirect.redirectReason?.toLowerCase() !== 'primary_domain_redirection' ||
|
|
73
|
+
redirect.poweredBy?.toLowerCase() !== 'shopify') {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return finalHost;
|
|
77
|
+
}
|
|
78
|
+
export async function navigationRedirectEvidence(finalResponse) {
|
|
79
|
+
const redirects = [];
|
|
80
|
+
let request = finalResponse?.request().redirectedFrom() ?? null;
|
|
81
|
+
while (request) {
|
|
82
|
+
const response = await request.response();
|
|
83
|
+
if (response) {
|
|
84
|
+
const headers = response.headers();
|
|
85
|
+
redirects.push({
|
|
86
|
+
sourceUrl: request.url(),
|
|
87
|
+
status: response.status(),
|
|
88
|
+
location: headers.location ?? null,
|
|
89
|
+
redirectReason: headers['x-redirect-reason'] ?? null,
|
|
90
|
+
poweredBy: headers['powered-by'] ?? null,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
request = request.redirectedFrom();
|
|
94
|
+
}
|
|
95
|
+
return redirects.reverse();
|
|
96
|
+
}
|