Skip to content
Draft
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@shopify/cli-kit': patch
'@shopify/theme': patch
'@shopify/store': patch
---

Fix theme and `store` commands failing with an unhelpful error on a preview store after it has been claimed; they now prompt to run `store auth` to re-authenticate
34 changes: 34 additions & 0 deletions packages/cli-kit/src/public/node/api/admin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as http from '../http.js'
import {defaultThemeKitAccessDomain} from '../../../private/node/constants.js'

import {test, vi, expect, describe} from 'vitest'
import {ClientError} from 'graphql-request'

vi.mock('./graphql.js')
vi.mock('../../../private/node/api/headers.js')
Expand Down Expand Up @@ -193,3 +194,36 @@ describe('admin-rest-api', () => {
)
})
})

describe('fetchApiVersions', () => {
// The HTTP status has to survive version discovery: callers such as the stored `store auth`
// recovery flow classify on the status, and they can't recover it from the rendered message.
test.each([401, 404])('preserves HTTP %i on the thrown error while keeping the message intact', async (status) => {
// Given a store whose API version has not been discovered yet
const session: AdminSession = {token, storeFqdn: `status-${status}.myshopify.com`}
vi.mocked(graphqlRequestDoc).mockRejectedValue(
new ClientError({status, data: 'body', errors: []}, {query: 'query'}),
)

// When
const error = await admin.fetchApiVersions(session).catch((thrown: unknown) => thrown)

// Then
expect(error).toBeInstanceOf(admin.AdminApiRequestError)
expect(error).toMatchObject({status})
expect((error as Error).message).toContain(`Error connecting to your store status-${status}.myshopify.com:`)
})

test('leaves other statuses to the existing classification', async () => {
// Given
const session: AdminSession = {token, storeFqdn: 'forbidden.myshopify.com'}
vi.mocked(graphqlRequestDoc).mockRejectedValue(new ClientError({status: 403, errors: []}, {query: 'query'}))

// When
const error = await admin.fetchApiVersions(session).catch((thrown: unknown) => thrown)

// Then
expect(error).not.toBeInstanceOf(admin.AdminApiRequestError)
expect((error as Error).message).toContain("Looks like you don't have access to this dev store")
})
})
24 changes: 23 additions & 1 deletion packages/cli-kit/src/public/node/api/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@ import {TypedDocumentNode} from '@graphql-typed-document-node/core'

const LatestApiVersionByFQDN = new Map<string, string>()

/**
* Thrown when the Admin API rejects a request with a status a caller may need to act on. It carries
* the HTTP status so callers can classify the failure - for instance a stored `shopify store auth`
* session that stopped being accepted once its preview store was claimed - rather than parsing the
* rendered message. It renders exactly like any other abort error.
*/
export class AdminApiRequestError extends AbortError {
/**
* HTTP status the Admin API responded with.
*/
public readonly status: number

constructor(status: number, message: string) {
super(message)
this.status = status
}
}

/**
* Executes a GraphQL query against the Admin API.
*
Expand Down Expand Up @@ -187,7 +205,11 @@ export async function fetchApiVersions(
)
}
if (error instanceof ClientError && (error.response.status === 401 || error.response.status === 404)) {
throw new AbortError(
// Every uncached `adminRequestDoc` runs version discovery first, so this is the dominant
// first-failure path. The status is preserved on the thrown error because higher-level
// classifiers (e.g. stored store-auth recovery) can't recover it from the message.
throw new AdminApiRequestError(
error.response.status,
`Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`,
)
}
Expand Down
223 changes: 223 additions & 0 deletions packages/cli-kit/src/public/node/store-auth-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
import {
throwIfStoredStoreAuthIsInvalid,
throwMissingStoredStoreAuthError,
throwReauthenticateStoreAuthError,
throwStoredStoreAuthInvalidError,
} from './store-auth-recovery.js'
import {STORE_AUTH_APP_CLIENT_ID} from './constants.js'
import {AbortError} from './error.js'
import {inTemporaryDirectory} from './fs.js'
import {LocalStorage} from './local-storage.js'
import {
getCurrentStoredStoreAppSession,
setStoredStoreAppSession,
type StoreAuthSessionSchema,
type StoredStoreAppSession,
} from './store-auth-session.js'
import {AdminApiRequestError} from './api/admin.js'
import {describe, expect, test} from 'vitest'

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,
}
}

// The shape graphql-request's `ClientError` has: the HTTP status nested under `response`. This is
// what escapes an Admin API call once the API version has been cached.
function graphQLClientError(status: number, message = 'GraphQL Error'): Error {
const error = new Error(message) as Error & {response: {status: number; errors: {message: string}[]}}
error.response = {status, errors: [{message}]}
return error
}

function captureThrown(run: () => void): AbortError | undefined {
try {
run()
} catch (error) {
if (!(error instanceof AbortError)) throw error
return error
}
return undefined
}

async function withStoredSession(
session: StoredStoreAppSession,
run: (storage: LocalStorage<StoreAuthSessionSchema>) => void,
): Promise<void> {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<StoreAuthSessionSchema>({cwd})
setStoredStoreAppSession(session, storage)

run(storage)
})
}

describe('throwMissingStoredStoreAuthError', () => {
test('reports no stored auth and prompts to authenticate (not re-authenticate) with a scopes placeholder', () => {
const captured = captureThrown(() => throwMissingStoredStoreAuthError(SHOP))

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', () => {
const captured = captureThrown(() => throwReauthenticateStoreAuthError('Custom message.', standardSession()))

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', () => {
const captured = captureThrown(() => throwReauthenticateStoreAuthError('Custom message.', previewSession()))

expect(captured).toMatchObject({
message: 'Custom message.',
nextSteps: [
[
'Run',
{command: `shopify store auth --store ${SHOP} --scopes <comma-separated-scopes>`},
'to re-authenticate',
],
],
})
})
})

describe('throwStoredStoreAuthInvalidError', () => {
test('uses the generic invalid-auth message and real scopes for a standard session', () => {
const captured = captureThrown(() => throwStoredStoreAuthInvalidError(standardSession()))

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', () => {
const captured = captureThrown(() => throwStoredStoreAuthInvalidError(previewSession()))

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('throwIfStoredStoreAuthIsInvalid', () => {
test.each([401, 404])('reports an invalid standard session and clears it for HTTP %i by default', async (status) => {
const session = standardSession()

await withStoredSession(session, (storage) => {
const captured = captureThrown(() =>
throwIfStoredStoreAuthIsInvalid(graphQLClientError(status), session, {storage}),
)

expect(captured).toBeInstanceOf(AbortError)
expect(captured).toMatchObject({message: `Stored app authentication for ${SHOP} is no longer valid.`})
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined()
})
})

test('recognizes the status carried directly by a typed transport error', async () => {
const session = standardSession()

await withStoredSession(session, (storage) => {
const transportError = new AdminApiRequestError(401, `Error connecting to your store ${SHOP}: Unauthorized`)

const captured = captureThrown(() => throwIfStoredStoreAuthIsInvalid(transportError, session, {storage}))

expect(captured).toMatchObject({message: `Stored app authentication for ${SHOP} is no longer valid.`})
})
})

// Clearing a preview session is what makes the `store auth` command this error prints runnable:
// `store auth` refuses to start while a preview session is still stored for the store.
test('clears an invalid preview session so the re-authentication it suggests can run', async () => {
const session = previewSession()

await withStoredSession(session, (storage) => {
const captured = captureThrown(() => throwIfStoredStoreAuthIsInvalid(graphQLClientError(401), session, {storage}))

expect(captured).toMatchObject({
message: `The preview store ${SHOP} has likely been claimed, so its stored authentication is no longer valid.`,
})
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined()
})
})

test('ignores a status the caller did not name, so a genuine not-found is not reported as invalid auth', async () => {
const session = standardSession()

await withStoredSession(session, (storage) => {
expect(() =>
throwIfStoredStoreAuthIsInvalid(graphQLClientError(404), session, {invalidStatuses: [401], storage}),
).not.toThrow()
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toMatchObject({accessToken: 'token'})
})
})

test.each([
['an unrelated HTTP status', graphQLClientError(500)],
['an error carrying no status at all', new Error('socket hang up')],
])('ignores %s', async (_description, error) => {
const session = standardSession()

await withStoredSession(session, (storage) => {
expect(() => throwIfStoredStoreAuthIsInvalid(error, session, {storage})).not.toThrow()
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toMatchObject({accessToken: 'token'})
})
})
})
Loading
Loading