Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/claimed-preview-store-auth-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/store': patch
---

Allow store auth recovery after a preview store is claimed.
7 changes: 1 addition & 6 deletions packages/store/src/cli/services/store/admin-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Original file line number Diff line number Diff line change
@@ -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<Parameters<typeof getCurrentStoredStoreAppSession>[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<unknown> {
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)
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/store/src/cli/services/store/info/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -231,7 +231,7 @@ describe('getStoreInfo', () => {
],
],
})
expect(clearStoredStoreAppSession).not.toHaveBeenCalled()
expect(clearStoredStoreAppSession).toHaveBeenCalledWith(SHOP, 'placeholder-uuid')
},
)

Expand Down Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
9 changes: 4 additions & 5 deletions packages/store/src/cli/services/store/info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -160,11 +160,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 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.
// 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)
}

Expand Down
Loading