From 496e69e80ce0b9f3c60b275bafd49e2f9020a935 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 21:53:01 +0300 Subject: [PATCH 01/14] Recover on `store info` after a preview store has been claimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI has no local signal for when a preview store gets claimed through the browser claim flow — the stored session keeps reporting `kind: 'preview'` forever. `store info` would then keep calling the preview-stores service with the stale preview token and surface a generic, non-actionable `Preview store lookup failed with HTTP 401` error instead of guiding the user to re-authenticate. - getPreviewStore now throws a typed PreviewStoreRequestError that carries the HTTP status, so callers can classify the failure instead of only seeing a pre-rendered message string. - getStoreInfo's preview-session path now treats a 401/404 from that service the same way the Admin API paths already treat a stale stored session: clear it and direct the user to `shopify store auth`, matching the pattern store execute already uses. - Cleaned up the redundant 'Next steps' rendering on the shared store-auth recovery errors (both the 'no stored auth' and 're-authenticate' cases), folding the instruction into the bullet itself instead of repeating it in a separate tryMessage line. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .changeset/store-info-preview-claim-reauth.md | 5 ++ .../src/cli/services/store/auth/recovery.ts | 14 +++- .../store/auth/session-lifecycle.test.ts | 16 +++- .../store/create/preview/client.test.ts | 14 ++++ .../services/store/create/preview/client.ts | 19 ++++- .../store/execute/admin-transport.test.ts | 18 ++++- .../src/cli/services/store/info/index.test.ts | 73 ++++++++++++++++++- .../src/cli/services/store/info/index.ts | 37 +++++++--- 8 files changed, 173 insertions(+), 23 deletions(-) create mode 100644 .changeset/store-info-preview-claim-reauth.md diff --git a/.changeset/store-info-preview-claim-reauth.md b/.changeset/store-info-preview-claim-reauth.md new file mode 100644 index 00000000000..f14ab041816 --- /dev/null +++ b/.changeset/store-info-preview-claim-reauth.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': patch +--- + +Fix `store info` failing with an unhelpful error on a preview store after it has been claimed; it now prompts to run `store auth` to re-authenticate diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 8b328701eb0..235c9665fea 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -8,16 +8,24 @@ function storeAuthCommandNextSteps(store: string, scopes: string) { return [[storeAuthCommand(store, scopes)]] } +// Folds the instruction into the "Next steps" bullet itself (`Run ... to `) instead of +// repeating it in a separate `tryMessage` line. Passing both a `tryMessage` like "To re-authenticate, +// run:" and a `nextSteps` list renders as a stuttering "To re-authenticate, run:\nNext steps\n • +// shopify store auth ..." — the "Next steps" heading already says what the list is for. +function storeAuthCommandNextStepsWithPurpose(store: string, scopes: string, purpose: string) { + return [['Run', storeAuthCommand(store, scopes), purpose]] +} + export function throwStoredStoreAuthError(store: string): never { throw new AbortError( `No stored app authentication found for ${store}.`, - 'To create stored auth for this store, run:', - storeAuthCommandNextSteps(store, ''), + undefined, + storeAuthCommandNextStepsWithPurpose(store, '', 'to create stored auth for this store'), ) } export function throwReauthenticateStoreAuthError(message: string, store: string, scopes: string): never { - throw new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes)) + throw new AbortError(message, undefined, storeAuthCommandNextStepsWithPurpose(store, scopes, 'to re-authenticate')) } export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts index 739b7b2f0ad..46dc510bd60 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts @@ -142,7 +142,13 @@ describe('loadStoredStoreSession', () => { await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ message: 'Token refresh failed for shop.myshopify.com (HTTP 401).', - tryMessage: 'To re-authenticate, run:', + nextSteps: [ + [ + 'Run', + {command: 'shopify store auth --store shop.myshopify.com --scopes read_products'}, + 'to re-authenticate', + ], + ], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') }) @@ -167,7 +173,13 @@ describe('loadStoredStoreSession', () => { await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ message: 'Token refresh returned an invalid response for shop.myshopify.com.', - tryMessage: 'To re-authenticate, run:', + nextSteps: [ + [ + 'Run', + {command: 'shopify store auth --store shop.myshopify.com --scopes read_products'}, + 'to re-authenticate', + ], + ], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') }) diff --git a/packages/store/src/cli/services/store/create/preview/client.test.ts b/packages/store/src/cli/services/store/create/preview/client.test.ts index c8a45d2fbde..e0f908471fd 100644 --- a/packages/store/src/cli/services/store/create/preview/client.test.ts +++ b/packages/store/src/cli/services/store/create/preview/client.test.ts @@ -6,6 +6,7 @@ import { getOrCreateCliInstanceId, previewStoreAuthenticatedHeaders, previewStoreCreateHeaders, + PreviewStoreRequestError, } from './client.js' import {shopifyFetch} from '@shopify/cli-kit/node/http' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' @@ -199,6 +200,19 @@ describe('preview store client', () => { }) }) + test.each([401, 404])('rejects preview store lookups with a %s-carrying error', async (status) => { + vi.mocked(shopifyFetch).mockResolvedValueOnce(response(status, {message: 'Not found'})) + + const error = await getPreviewStore( + {shopId: '123', adminApiToken: 'shpat_token'}, + {storage: inMemoryStorage('instance-1')}, + ).catch((caught: unknown) => caught) + + expect(error).toBeInstanceOf(PreviewStoreRequestError) + expect((error as PreviewStoreRequestError).status).toBe(status) + expect((error as PreviewStoreRequestError).message).toBe(`Preview store lookup failed with HTTP ${status}.`) + }) + test('omits the claim URL when the backend degrades it to null', async () => { vi.mocked(shopifyFetch).mockResolvedValueOnce( response(200, { diff --git a/packages/store/src/cli/services/store/create/preview/client.ts b/packages/store/src/cli/services/store/create/preview/client.ts index edf023d4a2f..be93d386d00 100644 --- a/packages/store/src/cli/services/store/create/preview/client.ts +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -82,6 +82,23 @@ interface RawPreviewStoreErrorResponse { message?: string } +/** + * Thrown by `getPreviewStore` for non-2xx responses so callers can classify the failure (e.g. a + * 401/404 that signals the preview store has since been claimed) instead of only seeing a + * pre-rendered message string. + */ +export class PreviewStoreRequestError extends Error { + public readonly status: number + public readonly tryMessage?: string + + constructor(status: number, message: string, tryMessage?: string) { + super(message) + this.name = 'PreviewStoreRequestError' + this.status = status + this.tryMessage = tryMessage + } +} + export function getOrCreateCliInstanceId( storage: LocalStorage = clientStorage(), ): string { @@ -175,7 +192,7 @@ export async function getPreviewStore( const rawText = await response.text() if (!response.ok) { const error = previewStoreGetError(response.status, rawText) - throw new AbortError(error.message, error.tryMessage) + throw new PreviewStoreRequestError(response.status, error.message, error.tryMessage) } let parsed: RawPreviewStoreGetResponse diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index 2e2f5a3e478..09312cc37ee 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -79,8 +79,13 @@ describe('runAdminStoreGraphQLOperation', () => { await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${store} --scopes read_products,write_orders`}, + 'to re-authenticate', + ], + ], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) @@ -212,8 +217,13 @@ describe('fetchPublicApiVersions', () => { await expect(fetchPublicApiVersions({adminSession, session})).rejects.toMatchObject({ message: `Stored app authentication for ${store} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${store} --scopes read_products,write_orders`}]], + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${store} --scopes read_products,write_orders`}, + 'to re-authenticate', + ], + ], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index a9182610be3..09a76d93672 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -4,7 +4,7 @@ import {fetchOrganizationShop} from './organization-shop.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {getPreviewStore} from '../create/preview/client.js' +import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' @@ -25,7 +25,13 @@ vi.mock('./organization-shop.js') vi.mock('../auth/session-lifecycle.js') vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('../attribution.js') -vi.mock('../create/preview/client.js') +vi.mock('../create/preview/client.js', async () => { + const actual = await vi.importActual('../create/preview/client.js') + return { + ...actual, + getPreviewStore: vi.fn(), + } +}) vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/api/admin', async () => { @@ -187,6 +193,64 @@ describe('getStoreInfo', () => { expect(result.adminUrl).toBeUndefined() }) + test.each([401, 404])( + 'clears the stale preview session and prompts re-auth when the preview store lookup returns %s', + async (status) => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({ + store: SHOP, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:placeholder-uuid', + accessToken: 'shpat_preview_token', + scopes: ['read_products'], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: { + placeholderAccountUuid: 'placeholder-uuid', + shopId: '123', + name: 'Lavender Candles', + createdAt: '2026-06-08T12:00:00.000Z', + accessUrl: 'https://app.shopify.com/auth/preview-store?token=stale-access-token', + }, + }) + vi.mocked(getPreviewStore).mockRejectedValueOnce( + new PreviewStoreRequestError(status, `Preview store lookup failed with HTTP ${status}.`), + ) + + await expect(getStoreInfo({store: SHOP})).rejects.toMatchObject({ + message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, + nextSteps: [ + ['Run', {command: `shopify store auth --store ${SHOP} --scopes read_products`}, 'to re-authenticate'], + ], + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, 'preview:placeholder-uuid') + }, + ) + + test('rethrows unrelated preview store lookup failures without clearing the session', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({ + store: SHOP, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:placeholder-uuid', + accessToken: 'shpat_preview_token', + scopes: ['read_products'], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: { + placeholderAccountUuid: 'placeholder-uuid', + shopId: '123', + name: 'Lavender Candles', + createdAt: '2026-06-08T12:00:00.000Z', + accessUrl: 'https://app.shopify.com/auth/preview-store?token=stale-access-token', + }, + }) + vi.mocked(getPreviewStore).mockRejectedValueOnce( + new PreviewStoreRequestError(500, 'Preview store lookup failed with HTTP 500.'), + ) + + await expect(getStoreInfo({store: SHOP})).rejects.toThrow('Preview store lookup failed with HTTP 500.') + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + test('surfaces cached Admin API scopes for locally stored preview stores', async () => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({ store: SHOP, @@ -484,8 +548,9 @@ The CLI is currently unable to prompt for reauthentication.`) await expect(getStoreInfo({store: SHOP})).rejects.toMatchObject({ message: `Stored app authentication for ${SHOP} is no longer valid.`, - tryMessage: 'To re-authenticate, run:', - nextSteps: [[{command: `shopify store auth --store ${SHOP} --scopes read_products`}]], + nextSteps: [ + ['Run', {command: `shopify store auth --store ${SHOP} --scopes read_products`}, 'to re-authenticate'], + ], }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, '42') }) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 09ef2002549..faf350e27e8 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -3,10 +3,11 @@ import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' +import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {getPreviewStore} from '../create/preview/client.js' +import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' -import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' +import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -147,13 +148,31 @@ interface PreviewStoreUrls { } async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promise { - const previewStore = await getPreviewStore({ - shopId: previewSession.preview.shopId, - adminApiToken: previewSession.accessToken, - }) - return { - accessUrl: previewStore.accessUrl, - ...(previewStore.claimUrl ? {saveUrl: previewStore.claimUrl} : {}), + try { + const previewStore = await getPreviewStore({ + shopId: previewSession.preview.shopId, + adminApiToken: previewSession.accessToken, + }) + return { + accessUrl: previewStore.accessUrl, + ...(previewStore.claimUrl ? {saveUrl: previewStore.claimUrl} : {}), + } + } catch (error) { + // The CLI has no local signal for when a preview store gets claimed through the browser + // claim flow; the stored session keeps reporting `kind: 'preview'` forever. A 401/404 from + // the preview-stores service is the first indication that the store has moved on and the + // cached preview token is no longer valid, so treat it the same way the Admin API paths + // treat a stale stored session: clear it and point the user at `store auth`. + if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { + clearStoredStoreAppSession(previewSession.store, previewSession.userId) + throwReauthenticateStoreAuthError( + `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, + previewSession.store, + previewSession.scopes.join(','), + ) + } + + throw error } } From 53383293675a56cfd17ba839a821f3023d2b7f55 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 22:45:47 +0300 Subject: [PATCH 02/14] Don't clear the stale preview session on `store info` re-auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against a real claimed store: clearing wasn't necessary (store auth overwrites the bucket's currentUserId regardless of what else is stored), and it caused a worse regression on retry — a second `store info` run silently fell through to a full interactive Business Platform login instead of repeating the actionable 'run store auth' guidance. Preview sessions also have no refresh-token cycle, so the wasted-retry justification for clearing standard sessions doesn't apply. Leaving the (already server-invalidated) session in place keeps every retry consistent until the user actually re-authenticates. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../store/src/cli/services/store/info/index.test.ts | 8 ++++++-- packages/store/src/cli/services/store/info/index.ts | 13 +++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 09a76d93672..6cddcd43a7d 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -194,7 +194,7 @@ describe('getStoreInfo', () => { }) test.each([401, 404])( - 'clears the stale preview session and prompts re-auth when the preview store lookup returns %s', + 'prompts re-auth without clearing the stale preview session when the preview store lookup returns %s', async (status) => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({ store: SHOP, @@ -222,7 +222,11 @@ describe('getStoreInfo', () => { ['Run', {command: `shopify store auth --store ${SHOP} --scopes read_products`}, 'to re-authenticate'], ], }) - expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, 'preview:placeholder-uuid') + // `store auth` overwrites the bucket's `currentUserId` when it stores a fresh session, so + // clearing this preview entry first isn't necessary — and not clearing it means repeated + // `store info` runs keep producing this same actionable message instead of silently + // falling through to a full interactive login on the next attempt. + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() }, ) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index faf350e27e8..6b48d7b4821 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -7,7 +7,7 @@ import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' -import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' +import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -161,10 +161,15 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi // The CLI has no local signal for when a preview store gets claimed through the browser // claim flow; the stored session keeps reporting `kind: 'preview'` forever. A 401/404 from // the preview-stores service is the first indication that the store has moved on and the - // cached preview token is no longer valid, so treat it the same way the Admin API paths - // treat a stale stored session: clear it and point the user at `store auth`. + // cached preview token is no longer valid. + // + // Deliberately left uncleared: `store auth` overwrites the bucket's `currentUserId` when it + // stores a fresh session, so nothing depends on this preview entry being removed first. And + // unlike a standard session's refresh-token flow, there's no automatic-retry loop here that + // clearing would protect against. Leaving it in place means every `store info` run before + // the user re-authenticates keeps producing this same actionable message, instead of + // silently falling through to a full interactive login on the next attempt. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { - clearStoredStoreAppSession(previewSession.store, previewSession.userId) throwReauthenticateStoreAuthError( `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, previewSession.store, From 38f40b1aa87f5e96c223686d643550ca41801e01 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 22:50:56 +0300 Subject: [PATCH 03/14] Simplify store-auth recovery next-steps text Drop the unnecessary comment and the generic 'purpose' parameter; both throwStoredStoreAuthError and throwReauthenticateStoreAuthError just say 'to re-authenticate'. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../store/src/cli/services/store/auth/recovery.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 235c9665fea..ae835c4ce9a 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -8,24 +8,20 @@ function storeAuthCommandNextSteps(store: string, scopes: string) { return [[storeAuthCommand(store, scopes)]] } -// Folds the instruction into the "Next steps" bullet itself (`Run ... to `) instead of -// repeating it in a separate `tryMessage` line. Passing both a `tryMessage` like "To re-authenticate, -// run:" and a `nextSteps` list renders as a stuttering "To re-authenticate, run:\nNext steps\n • -// shopify store auth ..." — the "Next steps" heading already says what the list is for. -function storeAuthCommandNextStepsWithPurpose(store: string, scopes: string, purpose: string) { - return [['Run', storeAuthCommand(store, scopes), purpose]] +function storeAuthCommandNextStepsToReauthenticate(store: string, scopes: string) { + return [['Run', storeAuthCommand(store, scopes), 'to re-authenticate']] } export function throwStoredStoreAuthError(store: string): never { throw new AbortError( `No stored app authentication found for ${store}.`, undefined, - storeAuthCommandNextStepsWithPurpose(store, '', 'to create stored auth for this store'), + storeAuthCommandNextStepsToReauthenticate(store, ''), ) } export function throwReauthenticateStoreAuthError(message: string, store: string, scopes: string): never { - throw new AbortError(message, undefined, storeAuthCommandNextStepsWithPurpose(store, scopes, 'to re-authenticate')) + throw new AbortError(message, undefined, storeAuthCommandNextStepsToReauthenticate(store, scopes)) } export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { From 5c5e2210566e151464240b0f8b807346a9d03612 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 22:53:53 +0300 Subject: [PATCH 04/14] Make PreviewStoreRequestError extend AbortError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extending the plain Error meant an uncaught PreviewStoreRequestError (any status other than 401/404) would surface to oclif as an unexpected bug rather than a clean, user-facing abort — inconsistent with how every other error in this module is handled. Extending AbortError fixes that and lets the class drop its own tryMessage field in favor of the inherited one. Also drop an unnecessary test comment restating what the test name already says. Addresses https://github.com/Shopify/cli/pull/7986#discussion_r3515476891 Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../store/src/cli/services/store/create/preview/client.ts | 7 ++----- packages/store/src/cli/services/store/info/index.test.ts | 4 ---- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/store/src/cli/services/store/create/preview/client.ts b/packages/store/src/cli/services/store/create/preview/client.ts index be93d386d00..5de06e37bab 100644 --- a/packages/store/src/cli/services/store/create/preview/client.ts +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -87,15 +87,12 @@ interface RawPreviewStoreErrorResponse { * 401/404 that signals the preview store has since been claimed) instead of only seeing a * pre-rendered message string. */ -export class PreviewStoreRequestError extends Error { +export class PreviewStoreRequestError extends AbortError { public readonly status: number - public readonly tryMessage?: string constructor(status: number, message: string, tryMessage?: string) { - super(message) - this.name = 'PreviewStoreRequestError' + super(message, tryMessage ?? null) this.status = status - this.tryMessage = tryMessage } } diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 6cddcd43a7d..221ecd89e05 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -222,10 +222,6 @@ describe('getStoreInfo', () => { ['Run', {command: `shopify store auth --store ${SHOP} --scopes read_products`}, 'to re-authenticate'], ], }) - // `store auth` overwrites the bucket's `currentUserId` when it stores a fresh session, so - // clearing this preview entry first isn't necessary — and not clearing it means repeated - // `store info` runs keep producing this same actionable message instead of silently - // falling through to a full interactive login on the next attempt. expect(clearStoredStoreAppSession).not.toHaveBeenCalled() }, ) From a46221ad72d8a9ad6df2f56205672d73854a1815 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 22:54:41 +0300 Subject: [PATCH 05/14] Trim overlong comment in store info's preview-claim handling Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../store/src/cli/services/store/info/index.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 6b48d7b4821..32c2c2eb2b6 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -158,17 +158,10 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi ...(previewStore.claimUrl ? {saveUrl: previewStore.claimUrl} : {}), } } catch (error) { - // The CLI has no local signal for when a preview store gets claimed through the browser - // claim flow; the stored session keeps reporting `kind: 'preview'` forever. A 401/404 from - // the preview-stores service is the first indication that the store has moved on and the - // cached preview token is no longer valid. - // - // Deliberately left uncleared: `store auth` overwrites the bucket's `currentUserId` when it - // stores a fresh session, so nothing depends on this preview entry being removed first. And - // unlike a standard session's refresh-token flow, there's no automatic-retry loop here that - // clearing would protect against. Leaving it in place means every `store info` run before - // the user re-authenticates keeps producing this same actionable message, instead of - // silently falling through to a full interactive login on the next attempt. + // The CLI has no local signal for when a preview store gets claimed via the browser; a + // 401/404 here is the first indication. The stored session is left uncleared on purpose: it + // isn't needed for `store auth` to take over, and keeping it means every `store info` run + // keeps producing this same actionable message instead of falling through to a full login. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { throwReauthenticateStoreAuthError( `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, From 79a2f3f1cad581e324893eaf86e14757c43e5c99 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 23:05:48 +0300 Subject: [PATCH 06/14] Don't re-list preview store scopes in the post-claim re-auth message Preview stores are preapproved for a large, fixed scope catalog (often 30+ scopes). Suggesting the user re-request all of them against what's now a live, claimed store encourages over-scoping. Use the same '' placeholder as the 'no stored auth' case instead, so they choose scopes deliberately. Exports the placeholder from recovery.ts so both call sites share one source of truth. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- packages/store/src/cli/services/store/auth/recovery.ts | 6 ++++-- .../store/src/cli/services/store/info/index.test.ts | 10 ++++++++-- packages/store/src/cli/services/store/info/index.ts | 7 +++++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index ae835c4ce9a..50c9df06929 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -1,5 +1,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' +export const UNKNOWN_SCOPES_PLACEHOLDER = '' + function storeAuthCommand(store: string, scopes: string): {command: string} { return {command: `shopify store auth --store ${store} --scopes ${scopes}`} } @@ -16,7 +18,7 @@ export function throwStoredStoreAuthError(store: string): never { throw new AbortError( `No stored app authentication found for ${store}.`, undefined, - storeAuthCommandNextStepsToReauthenticate(store, ''), + storeAuthCommandNextStepsToReauthenticate(store, UNKNOWN_SCOPES_PLACEHOLDER), ) } @@ -29,6 +31,6 @@ export function retryStoreAuthWithPermanentDomainError(returnedStore: string): A return new AbortError( 'OAuth callback store does not match the requested store.', `Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`, - storeAuthCommandNextSteps(returnedStore, ''), + storeAuthCommandNextSteps(returnedStore, UNKNOWN_SCOPES_PLACEHOLDER), ) } diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 221ecd89e05..9f29748e4b7 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -201,7 +201,9 @@ describe('getStoreInfo', () => { clientId: STORE_AUTH_APP_CLIENT_ID, userId: 'preview:placeholder-uuid', accessToken: 'shpat_preview_token', - scopes: ['read_products'], + // Preview stores are preapproved for a large, fixed scope catalog; the re-auth message + // should not dump the whole list back at the user (see the placeholder assertion below). + scopes: ['read_products', 'write_products', 'read_themes'], acquiredAt: '2026-06-08T12:00:00.000Z', kind: 'preview', preview: { @@ -219,7 +221,11 @@ describe('getStoreInfo', () => { await expect(getStoreInfo({store: SHOP})).rejects.toMatchObject({ message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, nextSteps: [ - ['Run', {command: `shopify store auth --store ${SHOP} --scopes read_products`}, 'to re-authenticate'], + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes `}, + 'to re-authenticate', + ], ], }) expect(clearStoredStoreAppSession).not.toHaveBeenCalled() diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 32c2c2eb2b6..3375f86c494 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -3,7 +3,7 @@ import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' +import {throwReauthenticateStoreAuthError, UNKNOWN_SCOPES_PLACEHOLDER} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' @@ -163,10 +163,13 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi // isn't needed for `store auth` to take over, and keeping it means every `store info` run // keeps producing this same actionable message instead of falling through to a full login. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { + // Preview stores are preapproved for a large, fixed catalog of scopes; blindly suggesting + // the user re-request all of them against what's now a live, claimed store would encourage + // over-scoping. Point at the placeholder instead so they choose deliberately. throwReauthenticateStoreAuthError( `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, previewSession.store, - previewSession.scopes.join(','), + UNKNOWN_SCOPES_PLACEHOLDER, ) } From e8df2515ac6b7fe34187b65d76f6de33a12aaa61 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 23:22:38 +0300 Subject: [PATCH 07/14] Fix the same scope-list-dumping bug in store execute throwIfStoredStoreAuthIsInvalid and runAdminStoreGraphQLOperation's inline 401 handler both suggested re-running `store auth` with the session's full scope list on re-auth. For a preview-store session (kind: 'preview') that list is the entire preapproved catalog (30+ scopes) rather than a deliberate choice, so store execute would dump the same unwieldy list store info did before the previous fix. - Added reauthScopesFor() in admin-errors.ts: preview-kind sessions get the placeholder, standard sessions keep their real (user-chosen) scopes. - runAdminStoreGraphQLOperation now delegates to throwIfStoredStoreAuthIsInvalid instead of duplicating the clear+reauth logic inline, which also fixes it not previously treating 404 as a stored-auth-invalid signal (fetchPublicApiVersions already did, via the shared helper). Verified against a real preview store with an invalidated token: store execute now shows the placeholder instead of the full scope list. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../src/cli/services/store/admin-errors.ts | 12 ++++- .../store/execute/admin-transport.test.ts | 54 +++++++++++++++++++ .../services/store/execute/admin-transport.ts | 11 +--- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index 4faa86e6149..05547a20bce 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -1,4 +1,4 @@ -import {throwReauthenticateStoreAuthError} from './auth/recovery.js' +import {throwReauthenticateStoreAuthError, UNKNOWN_SCOPES_PLACEHOLDER} from './auth/recovery.js' import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' @@ -55,6 +55,14 @@ export function classifyAdminApiError(error: unknown, storeFqdn: string): AbortE return undefined } +// Preview-store sessions are preapproved for a large, fixed scope catalog (often 30+ scopes). +// Suggesting the user re-request all of them via `store auth` encourages over-scoping, so a +// claimed preview store falls back to the same placeholder used when there's no stored auth at +// all, letting the user choose scopes deliberately instead. +export function reauthScopesFor(session: StoredStoreAppSession): string { + return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',') +} + export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredStoreAppSession): void { const status = graphQLClientErrorStatus(error) if (status !== 401 && status !== 404) return @@ -63,6 +71,6 @@ export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredS throwReauthenticateStoreAuthError( `Stored app authentication for ${session.store} is no longer valid.`, session.store, - session.scopes.join(','), + reauthScopesFor(session), ) } diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index 09312cc37ee..2b0b1fd1139 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -100,6 +100,40 @@ describe('runAdminStoreGraphQLOperation', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) + test('also treats a 404 as a stored-auth-no-longer-valid signal', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(404, 'Not Found')) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + + await expect(runAdminStoreGraphQLOperation({context, request})).rejects.toMatchObject({ + message: `Stored app authentication for ${store} is no longer valid.`, + }) + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') + }) + + test('does not re-list preapproved scopes when a lingering preview session 401s', async () => { + vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) + const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) + const previewContext = { + ...context, + session: { + ...context.session, + userId: 'preview:placeholder-uuid', + kind: 'preview' as const, + scopes: ['read_products', 'write_products', 'read_themes'], + }, + } + + await expect(runAdminStoreGraphQLOperation({context: previewContext, request})).rejects.toMatchObject({ + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${store} --scopes `}, + 'to re-authenticate', + ], + ], + }) + }) + test('throws a GraphQL operation error when errors are returned', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {errors: [{message: 'Field does not exist'}]}}) const request = await prepareStoreExecuteRequest({query: 'query { nope }'}) @@ -237,6 +271,26 @@ describe('fetchPublicApiVersions', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) + test('does not re-list preapproved scopes when a lingering preview session 401s', async () => { + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) + const previewSession = { + ...session, + userId: 'preview:placeholder-uuid', + kind: 'preview' as const, + scopes: ['read_products', 'write_products', 'read_themes'], + } + + await expect(fetchPublicApiVersions({adminSession, session: previewSession})).rejects.toMatchObject({ + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${store} --scopes `}, + 'to re-authenticate', + ], + ], + }) + }) + test('maps 402 Unavailable Shop to an AbortError without clearing stored auth', async () => { vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(402, 'Unavailable Shop')) diff --git a/packages/store/src/cli/services/store/execute/admin-transport.ts b/packages/store/src/cli/services/store/execute/admin-transport.ts index 8b24b546322..09aafa7932d 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -1,11 +1,9 @@ -import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import { classifyAdminApiError, isGraphQLClientErrorLike, throwIfStoredStoreAuthIsInvalid, ABORTED_FETCH_MESSAGE_FRAGMENTS, } from '../admin-errors.js' -import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' @@ -84,14 +82,7 @@ export async function runAdminStoreGraphQLOperation(input: { renderOptions: {stdout: process.stderr}, }) } catch (error) { - if (isGraphQLClientErrorLike(error) && error.response.status === 401) { - clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) - throwReauthenticateStoreAuthError( - `Stored app authentication for ${input.context.session.store} is no longer valid.`, - input.context.session.store, - input.context.session.scopes.join(','), - ) - } + throwIfStoredStoreAuthIsInvalid(error, input.context.session) // Status-specific classification (e.g. 402 store-unavailable) must run before the // generic GraphQL-errors branch, otherwise a 402 response that also carries From 4713727cbaacd5ef8aeb9e460dcd2e0ff21c2b4e Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 23:26:34 +0300 Subject: [PATCH 08/14] Reuse reauthScopesFor in store info's preview-claim path Avoids duplicating the placeholder-vs-real-scopes logic (and its explanatory comment) now that admin-errors.ts exports a shared helper for it. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- packages/store/src/cli/services/store/info/index.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 3375f86c494..ad73d1c44a9 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -1,9 +1,9 @@ import {StoreInfoBusinessPlatformStoreNotFoundError, fetchDestinationsContext} from './destinations.js' import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' -import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' +import {classifyAdminApiError, reauthScopesFor, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {throwReauthenticateStoreAuthError, UNKNOWN_SCOPES_PLACEHOLDER} from '../auth/recovery.js' +import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' @@ -163,13 +163,10 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi // isn't needed for `store auth` to take over, and keeping it means every `store info` run // keeps producing this same actionable message instead of falling through to a full login. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { - // Preview stores are preapproved for a large, fixed catalog of scopes; blindly suggesting - // the user re-request all of them against what's now a live, claimed store would encourage - // over-scoping. Point at the placeholder instead so they choose deliberately. throwReauthenticateStoreAuthError( `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, previewSession.store, - UNKNOWN_SCOPES_PLACEHOLDER, + reauthScopesFor(previewSession), ) } From 9426d29182ff2a9531d83cf38693de2148f20855 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 23:42:46 +0300 Subject: [PATCH 09/14] Simplify scope-placeholder logic and fix a related clearing gap throwReauthenticateStoreAuthError now takes the session directly and decides internally whether to show its real scopes or the placeholder, instead of every call site computing that via a separately exported reauthScopesFor() and passing the result in. Removes a small helper and its duplicate import from admin-errors.ts and info/index.ts. While consolidating, found and fixed a related gap: throwIfStoredStoreAuthIsInvalid (used by store execute and store info's Admin fallback) still unconditionally cleared any session on 401/404, including a lingering preview session. That reintroduced the exact 'second command falls through to a full interactive login' problem the earlier store info fix addressed, just via a different call path: running store execute against a claimed-but-uncleared preview store would clear it, and a follow-up store info would then have nothing to detect and prompt a full BP login instead of the actionable store auth message. Verified live: patched a real preview store's stored token to be invalid, ran store execute (session persists, correct message), then store info immediately after (same consistent message, no login prompt). Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../src/cli/services/store/admin-errors.ts | 24 +++++++------------ .../src/cli/services/store/auth/recovery.ts | 16 +++++++++++-- .../services/store/auth/session-lifecycle.ts | 10 +++----- .../store/execute/admin-transport.test.ts | 2 ++ .../src/cli/services/store/info/index.test.ts | 23 ++++++++++++++++++ .../src/cli/services/store/info/index.ts | 5 ++-- 6 files changed, 53 insertions(+), 27 deletions(-) diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index 05547a20bce..72ec48ccf31 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -1,4 +1,4 @@ -import {throwReauthenticateStoreAuthError, UNKNOWN_SCOPES_PLACEHOLDER} from './auth/recovery.js' +import {throwReauthenticateStoreAuthError} from './auth/recovery.js' import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' @@ -55,22 +55,16 @@ export function classifyAdminApiError(error: unknown, storeFqdn: string): AbortE return undefined } -// Preview-store sessions are preapproved for a large, fixed scope catalog (often 30+ scopes). -// Suggesting the user re-request all of them via `store auth` encourages over-scoping, so a -// claimed preview store falls back to the same placeholder used when there's no stored auth at -// all, letting the user choose scopes deliberately instead. -export function reauthScopesFor(session: StoredStoreAppSession): string { - return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',') -} - export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredStoreAppSession): void { const status = graphQLClientErrorStatus(error) if (status !== 401 && status !== 404) return - clearStoredStoreAppSession(session.store, session.userId) - throwReauthenticateStoreAuthError( - `Stored app authentication for ${session.store} is no longer valid.`, - session.store, - reauthScopesFor(session), - ) + // Preview-store sessions are left uncleared: `store auth` overwrites the bucket's + // `currentUserId` regardless, and clearing here would make a follow-up `store info` run + // fall through to a full interactive login instead of repeating this same actionable message. + if (session.kind !== 'preview') { + clearStoredStoreAppSession(session.store, session.userId) + } + + throwReauthenticateStoreAuthError(`Stored app authentication for ${session.store} is no longer valid.`, session) } diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 50c9df06929..55dad55a8e0 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -1,4 +1,5 @@ import {AbortError} from '@shopify/cli-kit/node/error' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' export const UNKNOWN_SCOPES_PLACEHOLDER = '' @@ -14,6 +15,13 @@ function storeAuthCommandNextStepsToReauthenticate(store: string, scopes: string return [['Run', storeAuthCommand(store, scopes), 'to re-authenticate']] } +// Preview-store sessions are preapproved for a large, fixed scope catalog (often 30+ scopes). +// Suggesting the user re-request all of them encourages over-scoping, so they get the same +// placeholder as the "no stored auth" case and choose deliberately instead. +function reauthScopesFor(session: StoredStoreAppSession): string { + return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',') +} + export function throwStoredStoreAuthError(store: string): never { throw new AbortError( `No stored app authentication found for ${store}.`, @@ -22,8 +30,12 @@ export function throwStoredStoreAuthError(store: string): never { ) } -export function throwReauthenticateStoreAuthError(message: string, store: string, scopes: string): never { - throw new AbortError(message, undefined, storeAuthCommandNextStepsToReauthenticate(store, scopes)) +export function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never { + throw new AbortError( + message, + undefined, + storeAuthCommandNextStepsToReauthenticate(session.store, reauthScopesFor(session)), + ) } export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.ts index a3c4dcf101d..5cb8c7f090c 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.ts @@ -61,11 +61,7 @@ export async function loadStoredStoreSession(store: string): Promise { ], ], }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() }) test('throws a GraphQL operation error when errors are returned', async () => { @@ -289,6 +290,7 @@ describe('fetchPublicApiVersions', () => { ], ], }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() }) test('maps 402 Unavailable Shop to an AbortError without clearing stored auth', async () => { diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 9f29748e4b7..6068b4d7274 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -561,6 +561,29 @@ The CLI is currently unable to prompt for reauthentication.`) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, '42') }) + test('does not re-list scopes or clear a lingering preview session that 401s against Admin', async () => { + mockStoreAuthFallback() + vi.mocked(loadStoredStoreSession).mockResolvedValue({ + ...STORED_SESSION, + userId: 'preview:placeholder-uuid', + kind: 'preview', + scopes: ['read_products', 'write_products', 'read_themes'], + }) + vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) + + await expect(getStoreInfo({store: SHOP})).rejects.toMatchObject({ + message: `Stored app authentication for ${SHOP} is no longer valid.`, + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes `}, + 'to re-authenticate', + ], + ], + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + test('also treats Admin 404 as a stored-auth-no-longer-valid signal', async () => { mockStoreAuthFallback() vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(404, 'Not Found')) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index ad73d1c44a9..37f701905d5 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -1,7 +1,7 @@ import {StoreInfoBusinessPlatformStoreNotFoundError, fetchDestinationsContext} from './destinations.js' import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' -import {classifyAdminApiError, reauthScopesFor, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' +import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' @@ -165,8 +165,7 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { throwReauthenticateStoreAuthError( `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, - previewSession.store, - reauthScopesFor(previewSession), + previewSession, ) } From dc77193600306b6cde3ef6344693404467ee2c59 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 2 Jul 2026 23:47:46 +0300 Subject: [PATCH 10/14] Drop dead scopes parameter from storeAuthCommandNextSteps It only had one caller left, always passing UNKNOWN_SCOPES_PLACEHOLDER. No behavior change. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- packages/store/src/cli/services/store/auth/recovery.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 55dad55a8e0..93190d5cda2 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -7,8 +7,8 @@ function storeAuthCommand(store: string, scopes: string): {command: string} { return {command: `shopify store auth --store ${store} --scopes ${scopes}`} } -function storeAuthCommandNextSteps(store: string, scopes: string) { - return [[storeAuthCommand(store, scopes)]] +function storeAuthCommandNextStepsWithUnknownScopes(store: string) { + return [[storeAuthCommand(store, UNKNOWN_SCOPES_PLACEHOLDER)]] } function storeAuthCommandNextStepsToReauthenticate(store: string, scopes: string) { @@ -43,6 +43,6 @@ export function retryStoreAuthWithPermanentDomainError(returnedStore: string): A return new AbortError( 'OAuth callback store does not match the requested store.', `Shopify returned ${returnedStore} during authentication. Re-run using the permanent store domain:`, - storeAuthCommandNextSteps(returnedStore, UNKNOWN_SCOPES_PLACEHOLDER), + storeAuthCommandNextStepsWithUnknownScopes(returnedStore), ) } From fff33f20185dc9dd8c4fa5144d60b19934e2bfa6 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Fri, 3 Jul 2026 00:10:16 +0300 Subject: [PATCH 11/14] Give the 3 stored-auth recovery cases distinct, consistent messages There are 3 cases: (1) no stored auth at all -> prompt auth, (2) stored auth invalid -> prompt reauth, (3) stored auth invalid for a preview store -> flag the likely claim and prompt reauth. Case 3 was previously only distinguished in store info's own preview-branch check; store execute and store info's Admin fallback both hit the same underlying situation via throwIfStoredStoreAuthIsInvalid but fell through to the generic case 2 message ('... is no longer valid.') even for a preview session. Added throwStoredAuthInvalidError(session) in recovery.ts to own the case 2 vs 3 message selection (mirroring the existing scope placeholder selection), and pointed every call site that detects an invalid stored session at it instead of hand-writing the message. Verified live: store execute against a preview store with an invalidated token now says 'likely been claimed', matching store info; a never-seen store still gets the distinct 'No stored app authentication found' message. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../store/src/cli/services/store/admin-errors.ts | 4 ++-- .../store/src/cli/services/store/auth/recovery.ts | 14 ++++++++++++++ .../services/store/execute/admin-transport.test.ts | 6 ++++-- .../src/cli/services/store/info/index.test.ts | 4 ++-- .../store/src/cli/services/store/info/index.ts | 7 ++----- 5 files changed, 24 insertions(+), 11 deletions(-) diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index 72ec48ccf31..791029397ca 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -1,4 +1,4 @@ -import {throwReauthenticateStoreAuthError} from './auth/recovery.js' +import {throwStoredAuthInvalidError} from './auth/recovery.js' import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' @@ -66,5 +66,5 @@ export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredS clearStoredStoreAppSession(session.store, session.userId) } - throwReauthenticateStoreAuthError(`Stored app authentication for ${session.store} is no longer valid.`, session) + throwStoredAuthInvalidError(session) } diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 93190d5cda2..c5463d8449b 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -38,6 +38,20 @@ export function throwReauthenticateStoreAuthError(message: string, session: Stor ) } +// A preview store's local session has no way to know it was claimed through the browser claim +// flow; a 401/404 the first time the stale session is used again is the only signal. Surfacing +// that possibility is more useful than the generic "no longer valid" message a standard session +// gets, so every call site that detects an invalid stored session (regardless of which API it +// hit) should go through here instead of writing its own message. +export function throwStoredAuthInvalidError(session: StoredStoreAppSession): never { + const message = + session.kind === 'preview' + ? `The preview store ${session.store} has likely been claimed, so its stored authentication is no longer valid.` + : `Stored app authentication for ${session.store} is no longer valid.` + + throwReauthenticateStoreAuthError(message, session) +} + export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError { // eslint-disable-next-line @shopify/cli/no-error-factory-functions return new AbortError( diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index d2c0e4e3b78..c6451dfc44d 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -110,7 +110,7 @@ describe('runAdminStoreGraphQLOperation', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) - test('does not re-list preapproved scopes when a lingering preview session 401s', async () => { + test('flags a likely claim and does not re-list scopes when a lingering preview session 401s', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) const previewContext = { @@ -124,6 +124,7 @@ describe('runAdminStoreGraphQLOperation', () => { } await expect(runAdminStoreGraphQLOperation({context: previewContext, request})).rejects.toMatchObject({ + message: `The preview store ${store} has likely been claimed, so its stored authentication is no longer valid.`, nextSteps: [ [ 'Run', @@ -272,7 +273,7 @@ describe('fetchPublicApiVersions', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) - test('does not re-list preapproved scopes when a lingering preview session 401s', async () => { + test('flags a likely claim and does not re-list scopes when a lingering preview session 401s', async () => { vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) const previewSession = { ...session, @@ -282,6 +283,7 @@ describe('fetchPublicApiVersions', () => { } await expect(fetchPublicApiVersions({adminSession, session: previewSession})).rejects.toMatchObject({ + message: `The preview store ${store} has likely been claimed, so its stored authentication is no longer valid.`, nextSteps: [ [ 'Run', diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 6068b4d7274..63532f8d7c8 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -561,7 +561,7 @@ The CLI is currently unable to prompt for reauthentication.`) expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, '42') }) - test('does not re-list scopes or clear a lingering preview session that 401s against Admin', async () => { + test('flags a likely claim (not a generic invalid-auth error) for a lingering preview session that 401s against Admin', async () => { mockStoreAuthFallback() vi.mocked(loadStoredStoreSession).mockResolvedValue({ ...STORED_SESSION, @@ -572,7 +572,7 @@ The CLI is currently unable to prompt for reauthentication.`) vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) await expect(getStoreInfo({store: SHOP})).rejects.toMatchObject({ - message: `Stored app authentication for ${SHOP} is no longer valid.`, + message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, nextSteps: [ [ 'Run', diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 37f701905d5..7fe73c6a3fc 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -3,7 +3,7 @@ import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' -import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' +import {throwStoredAuthInvalidError} from '../auth/recovery.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getPreviewStore, PreviewStoreRequestError} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' @@ -163,10 +163,7 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi // isn't needed for `store auth` to take over, and keeping it means every `store info` run // keeps producing this same actionable message instead of falling through to a full login. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { - throwReauthenticateStoreAuthError( - `The preview store ${previewSession.store} has likely been claimed, so its stored authentication is no longer valid.`, - previewSession, - ) + throwStoredAuthInvalidError(previewSession) } throw error From e56fd8d00fac0f722e26bdc6cbbb39ea47dd4609 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Fri, 3 Jul 2026 00:14:31 +0300 Subject: [PATCH 12/14] Say 'to authenticate' (not 're-authenticate') when there was no prior auth throwStoredStoreAuthError's next-steps bullet said 'to re-authenticate' even though case 1 (no stored auth at all) has nothing to re-do. Only cases 2/3 (stored auth exists but is invalid/likely claimed) should say 're-authenticate'. Verified live: an unseen store now says '... to authenticate'. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- packages/store/src/cli/services/store/auth/recovery.ts | 8 ++++---- .../src/cli/services/store/auth/session-lifecycle.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index c5463d8449b..802f0372332 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -11,8 +11,8 @@ function storeAuthCommandNextStepsWithUnknownScopes(store: string) { return [[storeAuthCommand(store, UNKNOWN_SCOPES_PLACEHOLDER)]] } -function storeAuthCommandNextStepsToReauthenticate(store: string, scopes: string) { - return [['Run', storeAuthCommand(store, scopes), 'to re-authenticate']] +function storeAuthCommandNextStepsWithPurpose(store: string, scopes: string, purpose: string) { + return [['Run', storeAuthCommand(store, scopes), purpose]] } // Preview-store sessions are preapproved for a large, fixed scope catalog (often 30+ scopes). @@ -26,7 +26,7 @@ export function throwStoredStoreAuthError(store: string): never { throw new AbortError( `No stored app authentication found for ${store}.`, undefined, - storeAuthCommandNextStepsToReauthenticate(store, UNKNOWN_SCOPES_PLACEHOLDER), + storeAuthCommandNextStepsWithPurpose(store, UNKNOWN_SCOPES_PLACEHOLDER, 'to authenticate'), ) } @@ -34,7 +34,7 @@ export function throwReauthenticateStoreAuthError(message: string, session: Stor throw new AbortError( message, undefined, - storeAuthCommandNextStepsToReauthenticate(session.store, reauthScopesFor(session)), + storeAuthCommandNextStepsWithPurpose(session.store, reauthScopesFor(session), 'to re-authenticate'), ) } diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts index 46dc510bd60..14d84e6872f 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts @@ -63,6 +63,13 @@ describe('loadStoredStoreSession', () => { await expect(loadStoredStoreSession('shop.myshopify.com')).rejects.toMatchObject({ message: 'No stored app authentication found for shop.myshopify.com.', + nextSteps: [ + [ + 'Run', + {command: 'shopify store auth --store shop.myshopify.com --scopes '}, + 'to authenticate', + ], + ], }) }) From 268a8fbcba126558c95d144693c72f729435ea2c Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Fri, 3 Jul 2026 00:26:33 +0300 Subject: [PATCH 13/14] Stop exporting UNKNOWN_SCOPES_PLACEHOLDER (knip: unused export) It's now only referenced within recovery.ts itself. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- packages/store/src/cli/services/store/auth/recovery.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 802f0372332..bcf2f95ae4d 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -1,7 +1,7 @@ import {AbortError} from '@shopify/cli-kit/node/error' import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' -export const UNKNOWN_SCOPES_PLACEHOLDER = '' +const UNKNOWN_SCOPES_PLACEHOLDER = '' function storeAuthCommand(store: string, scopes: string): {command: string} { return {command: `shopify store auth --store ${store} --scopes ${scopes}`} From 004e9f08b1b74edcf0a1ad5e1ef277a9fcd4e0c3 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Fri, 3 Jul 2026 14:09:12 +0300 Subject: [PATCH 14/14] Add dedicated unit tests for recovery.ts recovery.ts had accumulated real branching logic (reauthScopesFor's preview-vs-standard scope selection, throwStoredAuthInvalidError's message selection, the 'to authenticate' vs 'to re-authenticate' split) that was only exercised indirectly through other modules' tests. Add a recovery.test.ts covering each exported function directly, including both the standard-session and preview-session branches. Assisted-By: devx/952e932f-652c-498f-8052-b2f61ce56cf7 --- .../cli/services/store/auth/recovery.test.ts | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 packages/store/src/cli/services/store/auth/recovery.test.ts diff --git a/packages/store/src/cli/services/store/auth/recovery.test.ts b/packages/store/src/cli/services/store/auth/recovery.test.ts new file mode 100644 index 00000000000..add964a805e --- /dev/null +++ b/packages/store/src/cli/services/store/auth/recovery.test.ts @@ -0,0 +1,165 @@ +import { + throwStoredStoreAuthError, + throwReauthenticateStoreAuthError, + throwStoredAuthInvalidError, + retryStoreAuthWithPermanentDomainError, +} from './recovery.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test} from 'vitest' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' + +const SHOP = 'shop.myshopify.com' + +function standardSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: SHOP, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token', + scopes: ['read_products', 'write_orders'], + acquiredAt: '2026-03-27T00:00:00.000Z', + ...overrides, + } +} + +function previewSession(overrides: Partial = {}): StoredStoreAppSession { + return { + ...standardSession({ + userId: 'preview:placeholder-uuid', + // The full preapproved catalog is much larger in practice; a couple of entries are enough + // to prove the placeholder is used instead of these. + scopes: ['read_products', 'write_products', 'read_themes'], + }), + kind: 'preview', + preview: { + shopId: '123', + name: 'Lavender Candles', + createdAt: '2026-03-27T00:00:00.000Z', + }, + ...overrides, + } +} + +describe('throwStoredStoreAuthError', () => { + test('reports no stored auth and prompts to authenticate (not re-authenticate) with a scopes placeholder', () => { + let captured: AbortError | undefined + try { + throwStoredStoreAuthError(SHOP) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + captured = error as AbortError + } + + expect(captured).toMatchObject({ + message: `No stored app authentication found for ${SHOP}.`, + nextSteps: [ + ['Run', {command: `shopify store auth --store ${SHOP} --scopes `}, 'to authenticate'], + ], + }) + }) +}) + +describe('throwReauthenticateStoreAuthError', () => { + test('suggests the real scopes for a standard session', () => { + let captured: AbortError | undefined + try { + throwReauthenticateStoreAuthError('Custom message.', standardSession()) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + captured = error as AbortError + } + + expect(captured).toMatchObject({ + message: 'Custom message.', + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`}, + 'to re-authenticate', + ], + ], + }) + }) + + test('suggests a scopes placeholder for a preview session instead of its preapproved catalog', () => { + let captured: AbortError | undefined + try { + throwReauthenticateStoreAuthError('Custom message.', previewSession()) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + captured = error as AbortError + } + + expect(captured).toMatchObject({ + message: 'Custom message.', + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes `}, + 'to re-authenticate', + ], + ], + }) + }) +}) + +describe('throwStoredAuthInvalidError', () => { + test('uses the generic invalid-auth message and real scopes for a standard session', () => { + let captured: AbortError | undefined + try { + throwStoredAuthInvalidError(standardSession()) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + captured = error as AbortError + } + + expect(captured).toMatchObject({ + message: `Stored app authentication for ${SHOP} is no longer valid.`, + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`}, + 'to re-authenticate', + ], + ], + }) + }) + + test('flags a likely claim and suggests a scopes placeholder for a preview session', () => { + let captured: AbortError | undefined + try { + throwStoredAuthInvalidError(previewSession()) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + captured = error as AbortError + } + + expect(captured).toMatchObject({ + message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`, + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${SHOP} --scopes `}, + 'to re-authenticate', + ], + ], + }) + }) +}) + +describe('retryStoreAuthWithPermanentDomainError', () => { + test('returns (rather than throws) an AbortError pointing at the permanent domain with a scopes placeholder', () => { + const error = retryStoreAuthWithPermanentDomainError('permanent-shop.myshopify.com') + + expect(error).toBeInstanceOf(AbortError) + expect(error).toMatchObject({ + message: 'OAuth callback store does not match the requested store.', + tryMessage: + 'Shopify returned permanent-shop.myshopify.com during authentication. Re-run using the permanent store domain:', + nextSteps: [ + [{command: 'shopify store auth --store permanent-shop.myshopify.com --scopes '}], + ], + }) + }) +})