@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.
@@ -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
+ }