From c60d9c30174ecc5d3da10ee4987f79442f68668f Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 19 Aug 2026 11:24:21 -0400 Subject: [PATCH 1/2] Fix claimed preview store auth recovery Assisted-By: devx/f6cd24e9-3d53-4ab0-9851-a3eb94f559e8 --- .../claimed-preview-store-auth-recovery.md | 5 ++ .../src/cli/services/store/admin-errors.ts | 7 +-- .../store/auth/preview-claim-recovery.test.ts | 63 +++++++++++++++++++ .../store/execute/admin-transport.test.ts | 8 +-- .../src/cli/services/store/info/index.test.ts | 8 +-- .../src/cli/services/store/info/index.ts | 8 +-- 6 files changed, 81 insertions(+), 18 deletions(-) create mode 100644 .changeset/claimed-preview-store-auth-recovery.md create mode 100644 packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts diff --git a/.changeset/claimed-preview-store-auth-recovery.md b/.changeset/claimed-preview-store-auth-recovery.md new file mode 100644 index 00000000000..4abb1d0ea2b --- /dev/null +++ b/.changeset/claimed-preview-store-auth-recovery.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': patch +--- + +Allow store auth recovery after a preview store is claimed. diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index 791029397ca..bc2f224b616 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -59,12 +59,7 @@ export function throwIfStoredStoreAuthIsInvalid(error: unknown, session: StoredS const status = graphQLClientErrorStatus(error) if (status !== 401 && status !== 404) return - // 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) - } + clearStoredStoreAppSession(session.store, session.userId) throwStoredAuthInvalidError(session) } diff --git a/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts b/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts new file mode 100644 index 00000000000..faace03f95b --- /dev/null +++ b/packages/store/src/cli/services/store/auth/preview-claim-recovery.test.ts @@ -0,0 +1,63 @@ +import {authenticateStoreWithApp} from './index.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, + type StoredStoreAppSession, +} from '@shopify/cli-kit/node/store-auth-session' +import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {AbortError} from '@shopify/cli-kit/node/error' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('../attribution.js') + +const SHOP = 'shop.myshopify.com' +const SCOPE_RESOLUTION_REACHED = 'Scope resolution reached, so the preview-store guard did not fire.' + +type StoreAuthStorage = NonNullable[1]> + +function createStoreAuthStorage(cwd: string): StoreAuthStorage { + return new LocalStorage({cwd}) +} + +function previewSession(): StoredStoreAppSession { + return { + store: SHOP, + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:placeholder-uuid', + accessToken: 'shpat_preview_token', + scopes: ['read_themes', 'write_themes'], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: {shopId: '123', name: 'Lavender Candles', createdAt: '2026-06-08T12:00:00.000Z'}, + } +} + +function runStoreAuth(storage: StoreAuthStorage): Promise { + return authenticateStoreWithApp( + {store: SHOP, scopes: 'read_products'}, + { + getCurrentStoredStoreAppSession: (store) => getCurrentStoredStoreAppSession(store, storage), + resolveExistingScopes: () => Promise.reject(new AbortError(SCOPE_RESOLUTION_REACHED)), + }, + ) +} + +describe('recovering from a claimed preview store', () => { + test('clearing the invalid preview session unblocks the suggested `store auth` run', async () => { + await inTemporaryDirectory(async (cwd) => { + const storage = createStoreAuthStorage(cwd) + const session = previewSession() + setStoredStoreAppSession(session, storage) + + await expect(runStoreAuth(storage)).rejects.toThrow('`store auth` is unavailable for preview stores.') + + clearStoredStoreAppSession(session.store, session.userId, storage) + expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined() + + await expect(runStoreAuth(storage)).rejects.toThrow(SCOPE_RESOLUTION_REACHED) + }) + }) +}) 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 c6451dfc44d..c914c978cde 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('flags a likely claim and does not re-list scopes when a lingering preview session 401s', async () => { + test('clears a likely claimed preview session and does not re-list scopes when it 401s', async () => { vi.mocked(graphqlRequest).mockRejectedValue({response: {status: 401}}) const request = await prepareStoreExecuteRequest({query: 'query { shop { name } }'}) const previewContext = { @@ -133,7 +133,7 @@ describe('runAdminStoreGraphQLOperation', () => { ], ], }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, 'preview:placeholder-uuid') }) test('throws a GraphQL operation error when errors are returned', async () => { @@ -273,7 +273,7 @@ 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 () => { + test('clears a likely claimed preview session and does not re-list scopes when it 401s', async () => { vi.mocked(graphqlRequest).mockRejectedValue(makeClientErrorLike(401, 'Unauthorized')) const previewSession = { ...session, @@ -292,7 +292,7 @@ describe('fetchPublicApiVersions', () => { ], ], }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(store, 'preview:placeholder-uuid') }) 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 31ee079108f..6cebd2adae8 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -197,7 +197,7 @@ describe('getStoreInfo', () => { }) test.each([401, 404])( - 'prompts re-auth without clearing the stale preview session when the preview store lookup returns %s', + 'clears the stale preview session and prompts re-auth when the preview store lookup returns %s', async (status) => { vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({ store: SHOP, @@ -231,7 +231,7 @@ describe('getStoreInfo', () => { ], ], }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, 'placeholder-uuid') }, ) @@ -564,7 +564,7 @@ The CLI is currently unable to prompt for reauthentication.`) 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 () => { + test('clears a likely claimed preview session that 401s against Admin', async () => { mockStoreAuthFallback() vi.mocked(loadStoredStoreSession).mockResolvedValue({ ...STORED_SESSION, @@ -584,7 +584,7 @@ The CLI is currently unable to prompt for reauthentication.`) ], ], }) - expect(clearStoredStoreAppSession).not.toHaveBeenCalled() + expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, 'placeholder-uuid') }) test('also treats Admin 404 as a stored-auth-no-longer-valid signal', async () => { diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 974b7d4a619..9f376429c30 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 {getPreviewStore, PreviewStoreRequestError} from '../create/preview/clien import {storeTypeHandle} from '../store-type.js' import {StoreLookupStoreNotFoundError, fetchDestinationsContext} from '../../../utilities/store-lookup/destinations.js' import {fetchOrganizationShop} from '../../../utilities/store-lookup/organization-shop.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' @@ -161,10 +161,10 @@ async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promi } } 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. + // 401/404 here is the first indication. Remove the stale preview session so the `store auth` + // command in the recovery error is not blocked by the preview-store guard. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { + clearStoredStoreAppSession(previewSession.store, previewSession.userId) throwStoredAuthInvalidError(previewSession) } From 8f768d1dd2136b2a5d61309e6e3c6fbeec2f3daf Mon Sep 17 00:00:00 2001 From: Donald Merand Date: Wed, 19 Aug 2026 11:30:13 -0400 Subject: [PATCH 2/2] Clarify preview store recovery Assisted-By: devx/f6cd24e9-3d53-4ab0-9851-a3eb94f559e8 --- packages/store/src/cli/services/store/info/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 9f376429c30..3472a91d901 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -160,9 +160,8 @@ 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 via the browser; a - // 401/404 here is the first indication. Remove the stale preview session so the `store auth` - // command in the recovery error is not blocked by the preview-store guard. + // The CLI does not receive a claim event. A 401/404 is the first signal that the preview + // credential is invalid. Clear the session so the `store auth` command can run. if (error instanceof PreviewStoreRequestError && (error.status === 401 || error.status === 404)) { clearStoredStoreAppSession(previewSession.store, previewSession.userId) throwStoredAuthInvalidError(previewSession)