From 2c9d28bc186ef126ec3f317f98703f230c6f7fe0 Mon Sep 17 00:00:00 2001 From: River Date: Wed, 7 Oct 2026 13:02:17 +0000 Subject: [PATCH 1/2] Use the permanent store domain for Theme Access passwords Theme Access passwords are tied to a store's permanent .myshopify.com domain: the Theme Access proxy looks the password up by the X-Shopify-Shop header, so any other domain for the same store (for example a renamed .myshopify.com domain) fails with a 401 that reads like a wrong password. When the password is a Theme Access password, ensureAuthenticatedThemes now resolves the permanent domain from the store's public /meta.json (myshopify_domain), which needs no authentication and is served even when the storefront is password protected. Any lookup failure falls back to the domain as given, so existing setups behave as before. When a Theme Access password is still rejected, the error now says that passwords only work with the permanent domain and points at where to find it, instead of dumping the raw 401. Co-authored-by: Mbarak Bujra --- .changeset/theme-access-permanent-domain.md | 6 + .../session/permanent-store-domain.test.ts | 142 ++++++++++++++++++ .../node/session/permanent-store-domain.ts | 88 +++++++++++ .../cli-kit/src/public/node/api/admin.test.ts | 18 +++ packages/cli-kit/src/public/node/api/admin.ts | 16 ++ .../cli-kit/src/public/node/session.test.ts | 18 +++ packages/cli-kit/src/public/node/session.ts | 10 +- 7 files changed, 296 insertions(+), 2 deletions(-) create mode 100644 .changeset/theme-access-permanent-domain.md create mode 100644 packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts create mode 100644 packages/cli-kit/src/private/node/session/permanent-store-domain.ts diff --git a/.changeset/theme-access-permanent-domain.md b/.changeset/theme-access-permanent-domain.md new file mode 100644 index 00000000000..88805df1cfa --- /dev/null +++ b/.changeset/theme-access-permanent-domain.md @@ -0,0 +1,6 @@ +--- +'@shopify/cli-kit': patch +'@shopify/theme': patch +--- + +Theme Access passwords now work when `--store` is a store domain other than its permanent `.myshopify.com` domain (for example a renamed `.myshopify.com` domain): the CLI looks up the permanent domain and uses it. When a Theme Access password is rejected, the error now explains how to find the permanent domain. diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts new file mode 100644 index 00000000000..7fb0e2e55b5 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts @@ -0,0 +1,142 @@ +import {clearPermanentStoreFqdnCache, resolvePermanentStoreFqdn} from './permanent-store-domain.js' +import {fetch} from '../../../public/node/http.js' +import {mockAndCaptureOutput} from '../../../public/node/testing/output.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {Response} from 'node-fetch' + +vi.mock('../../../public/node/http.js') + +function metaJson(body: unknown, status = 200) { + return new Response(JSON.stringify(body), {status, headers: {'Content-Type': 'application/json'}}) +} + +describe('resolvePermanentStoreFqdn', () => { + beforeEach(() => { + clearPermanentStoreFqdnCache() + mockAndCaptureOutput().clear() + vi.unstubAllEnvs() + }) + + test("returns the store's permanent domain from its public meta.json", async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'abc123-xy.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + + // Then + expect(got).toEqual('abc123-xy.myshopify.com') + expect(fetch).toHaveBeenCalledWith( + 'https://renamed-store.myshopify.com/meta.json', + {headers: {Accept: 'application/json'}}, + {useNetworkLevelRetry: false, useAbortSignal: true, timeoutMs: 5000}, + ) + expect(outputMock.info()).toContain( + 'Using the permanent domain abc123-xy.myshopify.com for renamed-store.myshopify.com.', + ) + }) + + test('returns the domain unchanged, without a message, when it already is the permanent domain', async () => { + // Given + const outputMock = mockAndCaptureOutput() + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'my-store.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('my-store.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + expect(outputMock.info()).toEqual('') + }) + + test('normalises the case of the permanent domain', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'My-Store.myshopify.com'})) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + }) + + test('caches the result for the store and its permanent domain', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({myshopify_domain: 'abc123-xy.myshopify.com'})) + + // When + const first = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + const second = await resolvePermanentStoreFqdn('renamed-store.myshopify.com') + const third = await resolvePermanentStoreFqdn('abc123-xy.myshopify.com') + + // Then + expect([first, second, third]).toEqual([ + 'abc123-xy.myshopify.com', + 'abc123-xy.myshopify.com', + 'abc123-xy.myshopify.com', + ]) + expect(fetch).toHaveBeenCalledTimes(1) + }) + + test('returns the domain unchanged when meta.json is not a success', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson({errors: 'Not Found'}, 404)) + + // When + const got = await resolvePermanentStoreFqdn('my-store.myshopify.com') + + // Then + expect(got).toEqual('my-store.myshopify.com') + }) + + test.each([ + ['is missing', {}], + ['is not a string', {myshopify_domain: 42}], + ['is not a store domain', {myshopify_domain: 'evil.example.com'}], + ['contains a path', {myshopify_domain: 'my-store.myshopify.com/admin'}], + ])('returns the domain unchanged when myshopify_domain %s', async (_description, body) => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(metaJson(body)) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('returns the domain unchanged when meta.json is not JSON', async () => { + // Given + vi.mocked(fetch).mockResolvedValueOnce(new Response('Challenge', {status: 200})) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('returns the domain unchanged when the request fails', async () => { + // Given + vi.mocked(fetch).mockRejectedValueOnce(new Error('The operation was aborted')) + + // When + const got = await resolvePermanentStoreFqdn('alias.myshopify.com') + + // Then + expect(got).toEqual('alias.myshopify.com') + }) + + test('does not look up the domain against a local development server', async () => { + // Given + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + + // When + const got = await resolvePermanentStoreFqdn('my-store.shop.dev') + + // Then + expect(got).toEqual('my-store.shop.dev') + expect(fetch).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts new file mode 100644 index 00000000000..2e9eba6d373 --- /dev/null +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts @@ -0,0 +1,88 @@ +import {serviceEnvironment} from '../context/service.js' +import {fetch} from '../../../public/node/http.js' +import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js' + +/** + * How long to wait for a store's public `/meta.json` before giving up and using the domain as given. + */ +const PERMANENT_DOMAIN_LOOKUP_TIMEOUT_MS = 5000 + +/** + * A Shopify store domain: one or more DNS labels followed by one of the suffixes Shopify uses for store domains. + */ +const STORE_DOMAIN_REGEX = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:myshopify\.com|myshopify\.io|shop\.dev)$/ + +const resolvedStoreFqdns = new Map>() + +/** + * Resolves the permanent `.myshopify.com` domain of a store. + * + * A store can be reached through several domains (a renamed `.myshopify.com` domain, a custom domain), but some + * Shopify services only recognise the store's permanent domain. The Theme Access app is one of them: its passwords + * are tied to the permanent domain, so a request made with any other domain fails with a 401. + * + * The permanent domain is read from the store's public `/meta.json` endpoint, which needs no authentication and is + * served even when the storefront is password protected. When the lookup fails for any reason, the domain is + * returned unchanged, so callers behave exactly as they would without this lookup. + * + * Results are cached for the lifetime of the process. + * + * @param storeFqdn - The store domain the user provided, for example `my-store.myshopify.com`. + * @returns The store's permanent domain, or `storeFqdn` when it cannot be determined. + */ +export async function resolvePermanentStoreFqdn(storeFqdn: string): Promise { + if (serviceEnvironment() === 'local') return storeFqdn + + const cached = resolvedStoreFqdns.get(storeFqdn) + if (cached) return cached + + const resolution = fetchPermanentStoreFqdn(storeFqdn) + resolvedStoreFqdns.set(storeFqdn, resolution) + + const permanentStoreFqdn = await resolution + if (permanentStoreFqdn !== storeFqdn) { + resolvedStoreFqdns.set(permanentStoreFqdn, Promise.resolve(permanentStoreFqdn)) + outputInfo( + outputContent`Using the permanent domain ${outputToken.raw(permanentStoreFqdn)} for ${outputToken.raw( + storeFqdn, + )}.`, + ) + } + return permanentStoreFqdn +} + +/** + * Clears the in-process cache used by {@link resolvePermanentStoreFqdn}. Intended for tests. + */ +export function clearPermanentStoreFqdnCache(): void { + resolvedStoreFqdns.clear() +} + +async function fetchPermanentStoreFqdn(storeFqdn: string): Promise { + const url = `https://${storeFqdn}/meta.json` + try { + const response = await fetch( + url, + {headers: {Accept: 'application/json'}}, + {useNetworkLevelRetry: false, useAbortSignal: true, timeoutMs: PERMANENT_DOMAIN_LOOKUP_TIMEOUT_MS}, + ) + if (!response.ok) { + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${url} returned HTTP ${response.status}`) + return storeFqdn + } + + const body = (await response.json()) as {myshopify_domain?: unknown} | null + const permanentStoreFqdn = typeof body?.myshopify_domain === 'string' ? body.myshopify_domain.toLowerCase() : '' + if (!STORE_DOMAIN_REGEX.test(permanentStoreFqdn)) { + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${url} has no valid myshopify_domain`) + return storeFqdn + } + + return permanentStoreFqdn + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${message}`) + return storeFqdn + } +} diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index 976f8d2bf6a..0eb04c4e2d4 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -310,4 +310,22 @@ describe('fetchApiVersions error classification', () => { expect(unauthorized).toBeInstanceOf(AbortError) expect((unauthorized as AbortError).message).toContain('Error connecting to your store') }) + + test('explains a rejected Theme Access password, including how to find the permanent domain', async () => { + // Given + const themeAccessSession: AdminSession = {token: 'shptka_password', storeFqdn: 'alias.myshopify.com'} + vi.mocked(graphqlRequestDoc).mockRejectedValue(clientError(401, 'Unauthorized')) + + // When + const error = await admin.fetchApiVersions(themeAccessSession).catch((err: unknown) => err) + + // Then + expect(error).toBeInstanceOf(AbortError) + expect(shouldReportErrorAsUnexpected(error)).toBe(false) + expect((error as AbortError).message).toBe( + 'The Theme Access password was rejected for the store alias.myshopify.com.', + ) + expect(String((error as AbortError).tryMessage)).toContain('permanent .myshopify.com domain') + expect(JSON.stringify((error as AbortError).nextSteps)).toContain('https://alias.myshopify.com/meta.json') + }) }) diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index c70ad55e1f8..79d7739e3c6 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -186,6 +186,22 @@ export async function fetchApiVersions( outputContent`If you're not the owner, create a dev store staff account for yourself`, ) } + if (error instanceof ClientError && error.response.status === 401 && isThemeAccessSession(session)) { + throw new AbortError( + `The Theme Access password was rejected for the store ${session.storeFqdn}.`, + 'Theme Access passwords only work on the store they were generated for, and only with its permanent .myshopify.com domain.', + [ + 'Check that the password was created in the Theme Access app on this store and has not been deleted.', + [ + 'Pass the permanent domain to', + {command: '--store'}, + {char: '.'}, + 'It is the myshopify_domain value at', + {link: {url: `https://${session.storeFqdn}/meta.json`}}, + ], + ], + ) + } if (error instanceof ClientError && (error.response.status === 401 || error.response.status === 404)) { throw new AbortError( `Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`, diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index a0785a5a578..0d7774807fe 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -29,6 +29,7 @@ import { exchangeAppAutomationTokenForAppManagementAccessToken, exchangeAppAutomationTokenForBusinessPlatformAccessToken, } from '../../private/node/session/exchange.js' +import {resolvePermanentStoreFqdn} from '../../private/node/session/permanent-store-domain.js' import {vi, describe, expect, test} from 'vitest' @@ -44,6 +45,7 @@ vi.mock('../../private/node/session.js') vi.mock('../../private/node/session/exchange.js') vi.mock('../../private/node/session/store.js') vi.mock('../../private/node/session/automation-token.js') +vi.mock('../../private/node/session/permanent-store-domain.js') vi.mock('./environment.js') vi.mock('./http.js') @@ -284,9 +286,13 @@ describe('ensureAuthenticatedTheme', () => { expect(got).toEqual({token: 'password', storeFqdn: 'mystore.myshopify.com'}) expect(setLastSeenAuthMethod).toBeCalledWith('custom_app_token') expect(setLastSeenUserIdAfterAuth).toBeCalledWith(nonRandomUUID('password')) + expect(resolvePermanentStoreFqdn).not.toHaveBeenCalled() }) test('returns the password when is provided and theme_access_token', async () => { + // Given + vi.mocked(resolvePermanentStoreFqdn).mockImplementation(async (store) => store) + // When const got = await ensureAuthenticatedThemes('mystore.myshopify.com', 'shptka_password') @@ -295,6 +301,18 @@ describe('ensureAuthenticatedTheme', () => { expect(setLastSeenAuthMethod).toBeCalledWith('theme_access_token') expect(setLastSeenUserIdAfterAuth).toBeCalledWith(nonRandomUUID('shptka_password')) }) + + test("uses the store's permanent domain with a theme_access_token", async () => { + // Given + vi.mocked(resolvePermanentStoreFqdn).mockResolvedValueOnce('abc123-xy.myshopify.com') + + // When + const got = await ensureAuthenticatedThemes('renamed-store.myshopify.com', 'shptka_password') + + // Then + expect(resolvePermanentStoreFqdn).toHaveBeenCalledWith('renamed-store.myshopify.com') + expect(got).toEqual({token: 'shptka_password', storeFqdn: 'abc123-xy.myshopify.com'}) + }) }) describe('ensureAuthenticatedBusinessPlatform', () => { diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 659f9aa89d6..008effd3777 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -24,6 +24,7 @@ import { setLastSeenUserIdAfterAuth, } from '../../private/node/session.js' import {isThemeAccessSession} from '../../private/node/api/rest.js' +import {resolvePermanentStoreFqdn} from '../../private/node/session/permanent-store-domain.js' /** * Session Object to access the Admin API, includes the token and the store FQDN. @@ -275,6 +276,10 @@ ${outputToken.json(scopes)} * If a password is provided, that token will be used against Theme Access API. * Otherwise, it will ensure that the user is authenticated with the Admin API. * + * Theme Access passwords only work with the store's permanent `.myshopify.com` domain, so when the password is a + * Theme Access password the returned session uses the permanent domain of `store`, even if `store` is another of the + * store's domains. + * * @param store - Store fqdn to request auth for. * @param password - Password generated from Theme Access app. * @param scopes - Optional array of extra scopes to authenticate with. @@ -292,9 +297,10 @@ ${outputToken.json(scopes)} `) if (password) { const session = {token: password, storeFqdn: store} - const authMethod = isThemeAccessSession(session) ? 'theme_access_token' : 'custom_app_token' - setLastSeenAuthMethod(authMethod) + const themeAccess = isThemeAccessSession(session) + setLastSeenAuthMethod(themeAccess ? 'theme_access_token' : 'custom_app_token') setLastSeenUserIdAfterAuth(nonRandomUUID(password)) + if (themeAccess) session.storeFqdn = await resolvePermanentStoreFqdn(store) return session } return ensureAuthenticatedAdmin(store, scopes, options) From efce3a1e1f94bb9ce1c36f0ca219ae6d7b56eb7d Mon Sep 17 00:00:00 2001 From: River Date: Wed, 7 Oct 2026 17:54:38 +0000 Subject: [PATCH 2/2] Address review: reuse extractMyshopifyHandle, drop the meta.json link The permanent-domain lookup now accepts only a bare .myshopify.com, using the existing extractMyshopifyHandle helper instead of its own regex. myshopify.io and shop.dev are dropped: the lookup never runs against a local server, and production permanent domains are always .myshopify.com. The rejected-password error no longer links to /meta.json. That URL is built from the domain the user passed, so it is broken whenever that domain is not a real store. It now points to Settings > Domains in the Shopify admin. Co-authored-by: Mbarak Bujra --- .../node/session/permanent-store-domain.test.ts | 2 ++ .../private/node/session/permanent-store-domain.ts | 11 +++++------ packages/cli-kit/src/public/node/api/admin.test.ts | 5 ++++- packages/cli-kit/src/public/node/api/admin.ts | 5 +++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts index 7fb0e2e55b5..2045c4b9ce3 100644 --- a/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.test.ts @@ -94,6 +94,8 @@ describe('resolvePermanentStoreFqdn', () => { ['is missing', {}], ['is not a string', {myshopify_domain: 42}], ['is not a store domain', {myshopify_domain: 'evil.example.com'}], + ['is not a .myshopify.com domain', {myshopify_domain: 'my-store.myshopify.io'}], + ['has more than one label before .myshopify.com', {myshopify_domain: 'evil.my-store.myshopify.com'}], ['contains a path', {myshopify_domain: 'my-store.myshopify.com/admin'}], ])('returns the domain unchanged when myshopify_domain %s', async (_description, body) => { // Given diff --git a/packages/cli-kit/src/private/node/session/permanent-store-domain.ts b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts index 2e9eba6d373..a25222aaf0f 100644 --- a/packages/cli-kit/src/private/node/session/permanent-store-domain.ts +++ b/packages/cli-kit/src/private/node/session/permanent-store-domain.ts @@ -1,4 +1,5 @@ import {serviceEnvironment} from '../context/service.js' +import {extractMyshopifyHandle} from '../../../public/common/url.js' import {fetch} from '../../../public/node/http.js' import {outputContent, outputDebug, outputInfo, outputToken} from '../../../public/node/output.js' @@ -7,11 +8,6 @@ import {outputContent, outputDebug, outputInfo, outputToken} from '../../../publ */ const PERMANENT_DOMAIN_LOOKUP_TIMEOUT_MS = 5000 -/** - * A Shopify store domain: one or more DNS labels followed by one of the suffixes Shopify uses for store domains. - */ -const STORE_DOMAIN_REGEX = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+(?:myshopify\.com|myshopify\.io|shop\.dev)$/ - const resolvedStoreFqdns = new Map>() /** @@ -73,7 +69,10 @@ async function fetchPermanentStoreFqdn(storeFqdn: string): Promise { const body = (await response.json()) as {myshopify_domain?: unknown} | null const permanentStoreFqdn = typeof body?.myshopify_domain === 'string' ? body.myshopify_domain.toLowerCase() : '' - if (!STORE_DOMAIN_REGEX.test(permanentStoreFqdn)) { + // Accept only a bare `.myshopify.com`: `extractMyshopifyHandle` also accepts a URL with a path, so + // compare the round trip to reject anything but the domain itself. + const handle = extractMyshopifyHandle(permanentStoreFqdn) + if (!handle || `${handle}.myshopify.com` !== permanentStoreFqdn) { outputDebug(`Could not look up the permanent domain of ${storeFqdn}: ${url} has no valid myshopify_domain`) return storeFqdn } diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index 0eb04c4e2d4..d7b240b4323 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -326,6 +326,9 @@ describe('fetchApiVersions error classification', () => { 'The Theme Access password was rejected for the store alias.myshopify.com.', ) expect(String((error as AbortError).tryMessage)).toContain('permanent .myshopify.com domain') - expect(JSON.stringify((error as AbortError).nextSteps)).toContain('https://alias.myshopify.com/meta.json') + const nextSteps = JSON.stringify((error as AbortError).nextSteps) + expect(nextSteps).toContain('Settings > Domains') + // The domain the user passed may not be a real store, so the error must not send them to a URL built from it. + expect(nextSteps).not.toContain('alias.myshopify.com') }) }) diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index 79d7739e3c6..7ed3eda69a8 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -196,8 +196,9 @@ export async function fetchApiVersions( 'Pass the permanent domain to', {command: '--store'}, {char: '.'}, - 'It is the myshopify_domain value at', - {link: {url: `https://${session.storeFqdn}/meta.json`}}, + 'It is the .myshopify.com domain listed in the Shopify admin under', + {subdued: 'Settings > Domains'}, + {char: '.'}, ], ], )