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/store-info-preview-claim-reauth.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 9 additions & 7 deletions packages/store/src/cli/services/store/admin-errors.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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)
}
165 changes: 165 additions & 0 deletions packages/store/src/cli/services/store/auth/recovery.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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 <comma-separated-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 <comma-separated-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 <comma-separated-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 <comma-separated-scopes>'}],
],
})
})
})
46 changes: 39 additions & 7 deletions packages/store/src/cli/services/store/auth/recovery.ts
Comment thread
amcaplan marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,30 +1,62 @@
import {AbortError} from '@shopify/cli-kit/node/error'
import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session'

const UNKNOWN_SCOPES_PLACEHOLDER = '<comma-separated-scopes>'

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, '<comma-separated-scopes>'),
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 {
// eslint-disable-next-line @shopify/cli/no-error-factory-functions
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, '<comma-separated-scopes>'),
storeAuthCommandNextStepsWithUnknownScopes(returnedStore),
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 <comma-separated-scopes>'},
'to authenticate',
],
],
})
})

Expand Down Expand Up @@ -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')
})
Expand All @@ -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')
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,7 @@ export async function loadStoredStoreSession(store: string): Promise<StoredStore
}

if (!session.refreshToken) {
throwReauthenticateStoreAuthError(
`No refresh token stored for ${session.store}.`,
session.store,
session.scopes.join(','),
)
throwReauthenticateStoreAuthError(`No refresh token stored for ${session.store}.`, session)
}

outputDebug(
Expand All @@ -84,14 +80,14 @@ export async function loadStoredStoreSession(store: string): Promise<StoredStore
clearStoredStoreAppSession(session.store, session.userId)

if (error instanceof AbortError && error.message.startsWith(`Token refresh failed for ${session.store} (HTTP `)) {
throwReauthenticateStoreAuthError(error.message, session.store, session.scopes.join(','))
throwReauthenticateStoreAuthError(error.message, session)
}

if (
error instanceof AbortError &&
error.message === `Token refresh returned an invalid response for ${session.store}.`
) {
throwReauthenticateStoreAuthError(error.message, session.store, session.scopes.join(','))
throwReauthenticateStoreAuthError(error.message, session)
}

if (error instanceof AbortError && error.message === 'Received an invalid refresh response from Shopify.') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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, {
Expand Down
16 changes: 15 additions & 1 deletion packages/store/src/cli/services/store/create/preview/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<PreviewStoreClientStorageSchema> = clientStorage(),
): string {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading