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,5 @@
---
'@shopify/theme': patch
---

Prompt to re-authenticate when a claimed preview store is used with theme commands.
26 changes: 26 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 @@ -114,6 +115,31 @@ describe('admin-graphql-api', () => {
})
})

describe('fetchApiVersions', () => {
test.each([401, 404])('preserves HTTP %i on the thrown error', async (status) => {
vi.mocked(graphqlRequestDoc).mockRejectedValue(
new ClientError({status, data: 'body', errors: []}, {query: 'query'}),
)

const error = await admin
.fetchApiVersions({token, storeFqdn: `status-${status}.myshopify.com`})
.catch((thrown) => thrown)

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('keeps the existing access error for HTTP 403', async () => {
vi.mocked(graphqlRequestDoc).mockRejectedValue(new ClientError({status: 403, errors: []}, {query: 'query'}))

const error = await admin.fetchApiVersions({token, storeFqdn: 'forbidden.myshopify.com'}).catch((thrown) => thrown)

expect(error).not.toBeInstanceOf(admin.AdminApiRequestError)
expect((error as Error).message).toContain("Looks like you don't have access to this dev store")
})
})

describe('admin-rest-api', () => {
test('"#restRequest" returns a valid response', async () => {
// Given
Expand Down
13 changes: 12 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,16 @@ import {TypedDocumentNode} from '@graphql-typed-document-node/core'

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

/** Error that preserves an Admin API status for caller-specific recovery. */
export class AdminApiRequestError extends AbortError {
constructor(
public readonly status: number,
message: string,
) {
super(message)
}
}

/**
* Executes a GraphQL query against the Admin API.
*
Expand Down Expand Up @@ -187,7 +197,8 @@ export async function fetchApiVersions(
)
}
if (error instanceof ClientError && (error.response.status === 401 || error.response.status === 404)) {
throw new AbortError(
throw new AdminApiRequestError(
error.response.status,
`Error connecting to your store ${session.storeFqdn}: ${error.message} ${error.response.status} ${error.response.data}`,
)
}
Expand Down
144 changes: 144 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,144 @@
import {
throwIfStoredStoreAuthIsInvalid,
throwMissingStoredStoreAuthError,
throwReauthenticateStoreAuthError,
} from './store-auth-recovery.js'
import {
getCurrentStoredStoreAppSession,
setStoredStoreAppSession,
type StoreAuthSessionSchema,
type StoredStoreAppSession,
} from './store-auth-session.js'
import {STORE_AUTH_APP_CLIENT_ID} from './constants.js'
import {AdminApiRequestError} from './api/admin.js'
import {AbortError} from './error.js'
import {inTemporaryDirectory} from './fs.js'
import {LocalStorage} from './local-storage.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',
scopes: ['read_products', 'write_products', 'read_themes'],
}),
kind: 'preview',
preview: {
shopId: '123',
name: 'Lavender Candles',
createdAt: '2026-03-27T00:00:00.000Z',
},
...overrides,
}
}

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('stored store auth recovery', () => {
test('uses a scopes placeholder when stored authentication is missing', () => {
const error = captureThrown(() => throwMissingStoredStoreAuthError(SHOP))

expect(error).toMatchObject({
message: `No stored app authentication found for ${SHOP}.`,
nextSteps: [
['Run', {command: `shopify store auth --store ${SHOP} --scopes <comma-separated-scopes>`}, 'to authenticate'],
],
})
})

test('uses the stored scopes for a standard session', () => {
const error = captureThrown(() => throwReauthenticateStoreAuthError('Custom message.', standardSession()))

expect(error).toMatchObject({
message: 'Custom message.',
nextSteps: [
[
'Run',
{command: `shopify store auth --store ${SHOP} --scopes read_products,write_orders`},
'to re-authenticate',
],
],
})
})

test('clears a preview session and reports a likely claim for a typed 401', async () => {
const session = previewSession()

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

expect(error).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',
],
],
})
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined()
})
})

test('keeps a stored session on a 404 under the default 401-only policy', async () => {
const session = standardSession()

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

test('clears a stored session on a 404 when the caller classifies it explicitly', async () => {
const session = standardSession()

await withStoredSession(session, (storage) => {
const error = captureThrown(() =>
throwIfStoredStoreAuthIsInvalid({response: {status: 404}}, session, {invalidStatuses: [401, 404], storage}),
)

expect(error).toMatchObject({message: `Stored app authentication for ${SHOP} is no longer valid.`})
expect(getCurrentStoredStoreAppSession(SHOP, storage)).toBeUndefined()
})
})
})
105 changes: 105 additions & 0 deletions packages/cli-kit/src/public/node/store-auth-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {clearStoredStoreAppSession} from './store-auth-session.js'
import {AbortError} from './error.js'
import type {LocalStorage} from './local-storage.js'
import type {StoreAuthSessionSchema, StoredStoreAppSession} from './store-auth-session.js'

const UNKNOWN_SCOPES_PLACEHOLDER = '<comma-separated-scopes>'
// Only 401 always means rejected credentials. Callers whose 404 also signals a
// removed store or token must opt in with explicit statuses.
const DEFAULT_INVALID_STORE_AUTH_STATUSES: ReadonlyArray<number> = [401]

function storeAuthCommandNextSteps(store: string, scopes: string, purpose: string) {
return [['Run', {command: `shopify store auth --store ${store} --scopes ${scopes}`}, purpose]]
}

function reauthScopesFor(session: StoredStoreAppSession): string {
return session.kind === 'preview' ? UNKNOWN_SCOPES_PLACEHOLDER : session.scopes.join(',')
}

// API errors can store their status on `status`, `statusCode`, or `response.status`.
function httpStatusFromError(error: unknown): number | undefined {
if (!error || typeof error !== 'object') return undefined

const {status, statusCode, response} = error as {status?: unknown; statusCode?: unknown; response?: unknown}
if (typeof status === 'number') return status
if (typeof statusCode === 'number') return statusCode
if (!response || typeof response !== 'object') return undefined

const responseStatus = (response as {status?: unknown}).status
return typeof responseStatus === 'number' ? responseStatus : undefined
}

/**
* Throws an actionable error when no store app authentication is stored.
*
* @param store - The store FQDN that needs authentication.
* @throws AbortError with a `store auth` next step.
*/
export function throwMissingStoredStoreAuthError(store: string): never {
throw new AbortError(
`No stored app authentication found for ${store}.`,
undefined,
storeAuthCommandNextSteps(store, UNKNOWN_SCOPES_PLACEHOLDER, 'to authenticate'),
)
}

/**
* Throws an actionable error that directs the user to re-authenticate a stored session.
*
* @param message - The reason that the stored session cannot be used.
* @param session - The stored session to authenticate again.
* @throws AbortError with a `store auth` next step.
*/
export function throwReauthenticateStoreAuthError(message: string, session: StoredStoreAppSession): never {
throw new AbortError(
message,
undefined,
storeAuthCommandNextSteps(session.store, reauthScopesFor(session), 'to re-authenticate'),
)
}

/**
* Throws the invalid-session error for a stored store app session.
*
* @param session - The stored session rejected by Shopify.
* @throws AbortError with a `store auth` next step.
*/
export function throwStoredStoreAuthInvalidError(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)
}

interface InvalidStoredStoreAuthOptions {
invalidStatuses?: ReadonlyArray<number>
storage?: LocalStorage<StoreAuthSessionSchema>
}

/**
* Clears and reports a rejected stored session when its HTTP status is invalid for the caller.
*
* @param error - The rejected API error.
* @param session - The stored session used for the request.
* @param options - Statuses to classify and an optional storage override.
* @throws AbortError with a `store auth` next step when the session is invalid.
*/
export function throwIfStoredStoreAuthIsInvalid(
error: unknown,
session: StoredStoreAppSession,
options: InvalidStoredStoreAuthOptions = {},
): void {
const invalidStatuses = options.invalidStatuses ?? DEFAULT_INVALID_STORE_AUTH_STATUSES
const status = httpStatusFromError(error)
if (status === undefined || !invalidStatuses.includes(status)) return

if (options.storage) {
clearStoredStoreAppSession(session.store, session.userId, options.storage)
} else {
clearStoredStoreAppSession(session.store, session.userId)
}

throwStoredStoreAuthInvalidError(session)
}
20 changes: 20 additions & 0 deletions packages/cli-kit/src/public/node/store-auth-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
clearStoredStoreAppSession,
getCurrentStoredStoreAppSession,
getStoreAuthAdminSession,
listCurrentStoredStoreAppSessions,
setStoredStoreAppSession,
storeAuthSessionKey,
type StoredStoreAppSession,
Expand Down Expand Up @@ -116,6 +117,25 @@ describe('store auth session storage', () => {
})
})

test('lists the current stored session for every store from real local storage', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<Record<string, unknown>>({cwd})
const previousFirstStoreUser = buildSession({store: 'first.myshopify.com', userId: '42', accessToken: 'token-1'})
const currentFirstStoreUser = buildSession({store: 'first.myshopify.com', userId: '84', accessToken: 'token-2'})
const currentSecondStoreUser = buildSession({store: 'second.myshopify.com', userId: '7', accessToken: 'token-3'})

setStoredStoreAppSession(previousFirstStoreUser, storage as any)
setStoredStoreAppSession(currentFirstStoreUser, storage as any)
setStoredStoreAppSession(currentSecondStoreUser, storage as any)
storage.set('unrelated-key', {currentUserId: '42', sessionsByUserId: {}})

const sessions = listCurrentStoredStoreAppSessions(storage as any)

expect(sessions).toHaveLength(2)
expect(sessions).toEqual(expect.arrayContaining([currentFirstStoreUser, currentSecondStoreUser]))
})
})

test('returns the current user session for a store', () => {
const storage = inMemoryStorage()

Expand Down
3 changes: 2 additions & 1 deletion packages/cli-kit/src/public/node/store-auth-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ interface StoredStoreAppSessionBucket {
sessionsByUserId: {[userId: string]: StoredStoreAppSession}
}

interface StoreAuthSessionSchema {
/** Store auth sessions, keyed by the store auth storage key. */
export interface StoreAuthSessionSchema {
[key: string]: StoredStoreAppSessionBucket
}

Expand Down
Loading
Loading