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/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index 4faa86e6149..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' @@ -59,10 +59,12 @@ export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredS 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, - session.scopes.join(','), - ) + // 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) + } + + throwStoredAuthInvalidError(session) } 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 '}], + ], + }) + }) +}) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 8b328701eb0..bcf2f95ae4d 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -1,23 +1,55 @@ import {AbortError} from '@shopify/cli-kit/node/error' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' + +const UNKNOWN_SCOPES_PLACEHOLDER = '' 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 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). +// 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}.`, - 'To create stored auth for this store, run:', - storeAuthCommandNextSteps(store, ''), + undefined, + storeAuthCommandNextStepsWithPurpose(store, UNKNOWN_SCOPES_PLACEHOLDER, 'to authenticate'), ) } -export function throwReauthenticateStoreAuthError(message: string, store: string, scopes: string): never { - throw new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes)) +export function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never { + throw new AbortError( + message, + undefined, + storeAuthCommandNextStepsWithPurpose(session.store, reauthScopesFor(session), 'to re-authenticate'), + ) +} + +// 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 { @@ -25,6 +57,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, ''), + storeAuthCommandNextStepsWithUnknownScopes(returnedStore), ) } 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..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', + ], + ], }) }) @@ -142,7 +149,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 +180,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/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 { }) }) + 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..5de06e37bab 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,20 @@ 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 AbortError { + public readonly status: number + + constructor(status: number, message: string, tryMessage?: string) { + super(message, tryMessage ?? null) + this.status = status + } +} + export function getOrCreateCliInstanceId( storage: LocalStorage = clientStorage(), ): string { @@ -175,7 +189,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..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 @@ -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') }) @@ -95,6 +100,42 @@ 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('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 = { + ...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({ + message: `The preview store ${store} has likely been claimed, so its stored authentication is no longer valid.`, + nextSteps: [ + [ + 'Run', + {command: `shopify store auth --store ${store} --scopes `}, + 'to re-authenticate', + ], + ], + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + 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 }'}) @@ -212,8 +253,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') }) @@ -227,6 +273,28 @@ describe('fetchPublicApiVersions', () => { expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, '42') }) + 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, + userId: 'preview:placeholder-uuid', + kind: 'preview' as const, + scopes: ['read_products', 'write_products', 'read_themes'], + } + + 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', + {command: `shopify store auth --store ${store} --scopes `}, + 'to re-authenticate', + ], + ], + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }) + 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 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..63532f8d7c8 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,70 @@ describe('getStoreInfo', () => { expect(result.adminUrl).toBeUndefined() }) + test.each([401, 404])( + 'prompts re-auth without clearing the stale preview session 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', + // 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: { + 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 `}, + 'to re-authenticate', + ], + ], + }) + expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + }, + ) + + 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,12 +554,36 @@ 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') }) + 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, + 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: `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', + ], + ], + }) + 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 09ef2002549..7fe73c6a3fc 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -3,8 +3,9 @@ import {fetchOrganizationShop} from './organization-shop.js' import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' +import {throwStoredAuthInvalidError} 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 {AbortError} from '@shopify/cli-kit/node/error' @@ -147,13 +148,25 @@ 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 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)) { + throwStoredAuthInvalidError(previewSession) + } + + throw error } }