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
5 changes: 5 additions & 0 deletions .changeset/preview-store-session-discriminator.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/store': minor
---

Add a `kind` discriminator (`'standard' | 'preview'`) and optional `preview` metadata to stored store auth sessions, plus a recovery dispatcher that surfaces a preview-specific error when a placeholder-owned session can no longer be reached. Internal scaffolding for the upcoming `shopify store create preview` command — no user-visible change yet. Sessions written before this field existed continue to read back as `'standard'`.
114 changes: 114 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,114 @@
import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {
throwReauthenticateForSession,
throwReauthenticatePreviewStoreError,
throwReauthenticateStoreAuthError,
throwStoredStoreAuthError,
} from './recovery.js'
import {type StoredStoreAppSession} from './session-store.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {describe, expect, test} from 'vitest'

function buildStandardSession(overrides: Partial<StoredStoreAppSession> = {}): StoredStoreAppSession {
return {
store: 'shop.myshopify.com',
clientId: STORE_AUTH_APP_CLIENT_ID,
userId: '42',
accessToken: 'token',
scopes: ['read_products', 'write_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
...overrides,
}
}

function buildPreviewSession(overrides: Partial<StoredStoreAppSession> = {}): StoredStoreAppSession {
return {
store: 'preview-1.myshopify.io',
clientId: STORE_AUTH_APP_CLIENT_ID,
userId: 'placeholder:abc',
accessToken: 'shpat_preview_token',
scopes: ['read_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
kind: 'preview',
preview: {
placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
coreUrl: 'https://app.shop.dev',
},
...overrides,
}
}

describe('throwStoredStoreAuthError', () => {
test('points the user at `shopify store auth` with a placeholder for scopes', () => {
expect(() => throwStoredStoreAuthError('shop.myshopify.com')).toThrow(AbortError)

try {
throwStoredStoreAuthError('shop.myshopify.com')
} catch (error) {
const thrown = error as AbortError
expect(thrown.message).toBe('No stored app authentication found for shop.myshopify.com.')
expect(thrown.tryMessage).toBe('To create stored auth for this store, run:')
}
})
})

describe('throwReauthenticateStoreAuthError', () => {
test('preserves the caller message and surfaces the `shopify store auth` next-step', () => {
try {
throwReauthenticateStoreAuthError('boom', 'shop.myshopify.com', 'read_products,write_products')
} catch (error) {
const thrown = error as AbortError
expect(thrown.message).toBe('boom')
expect(thrown.tryMessage).toBe('To re-authenticate, run:')
}
})
})

describe('throwReauthenticatePreviewStoreError', () => {
test('does not suggest the standard `shopify store auth` PKCE flow', () => {
try {
throwReauthenticatePreviewStoreError('boom', 'preview-1.myshopify.io')
} catch (error) {
const thrown = error as AbortError
expect(thrown.message).toBe('boom')
expect(thrown.tryMessage).toContain("Preview store sessions can't be refreshed")
expect(JSON.stringify(thrown)).not.toContain('shopify store auth')
}
})
})

describe('throwReauthenticateForSession', () => {
test('routes a standard session to the PKCE re-auth helper', () => {
try {
throwReauthenticateForSession('boom', buildStandardSession())
} catch (error) {
const thrown = error as AbortError
expect(thrown.tryMessage).toBe('To re-authenticate, run:')
expect(JSON.stringify(thrown)).toContain('shopify store auth --store shop.myshopify.com')
}
})

test('routes a preview-kind session to the preview-specific recovery helper', () => {
try {
throwReauthenticateForSession('boom', buildPreviewSession())
} catch (error) {
const thrown = error as AbortError
expect(thrown.tryMessage).toContain("Preview store sessions can't be refreshed")
expect(JSON.stringify(thrown)).not.toContain('shopify store auth')
}
})

test('falls back to the standard helper when the kind is preview but metadata is missing', () => {
// Defensive case: a session marked `kind: 'preview'` without `preview` metadata could
// not be re-minted anyway, but `isPreviewStoreSession` requires both. We treat it as
// a malformed standard session and surface the existing helper rather than crashing.
const malformed = {...buildPreviewSession(), preview: undefined}

try {
throwReauthenticateForSession('boom', malformed)
} catch (error) {
const thrown = error as AbortError
expect(thrown.tryMessage).toBe('To re-authenticate, run:')
}
})
})
27 changes: 27 additions & 0 deletions packages/store/src/cli/services/store/auth/recovery.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {isPreviewStoreSession, type StoredStoreAppSession} from './session-store.js'
import {AbortError} from '@shopify/cli-kit/node/error'

function storeAuthCommand(store: string, scopes: string): {command: string} {
Expand All @@ -20,6 +21,32 @@ export function throwReauthenticateStoreAuthError(message: string, store: string
throw new AbortError(message, 'To re-authenticate, run:', storeAuthCommandNextSteps(store, scopes))
}

/**
* Recovery error for preview-store sessions, which can't be re-authenticated through
* the PKCE flow because they're owned by a placeholder identity. The follow-up command
* for recreating a preview store will be wired in when `shopify store create preview`
* lands; until then we surface a generic recovery message.
*/
export function throwReauthenticatePreviewStoreError(message: string, store: string): never {
throw new AbortError(
message,
`Preview store sessions can't be refreshed automatically. Recreate the preview store to obtain a new token.`,
[[`The preview store ${store} can no longer be reached with the stored token.`]],
)
}

/**
* Dispatches to the right recovery helper based on the session kind. Preview-store
* sessions surface a distinct error because the PKCE re-auth path is not available to
* them.
*/
export function throwReauthenticateForSession(message: string, session: StoredStoreAppSession): never {
if (isPreviewStoreSession(session)) {
throwReauthenticatePreviewStoreError(message, session.store)
}
throwReauthenticateStoreAuthError(message, session.store, session.scopes.join(','))
}

export function retryStoreAuthWithPermanentDomainError(returnedStore: string): AbortError {
// eslint-disable-next-line @shopify/cli/no-error-factory-functions
return new AbortError(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,19 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js'
import {AbortError} from '@shopify/cli-kit/node/error'
import {describe, expect, test, vi} from 'vitest'

vi.mock('./session-store.js')
vi.mock('./session-store.js', async () => {
// Auto-mock the storage helpers (so each test can stub their return values), but keep
// the pure helpers (`sessionKind`, `isPreviewStoreSession`) backed by the real impl —
// session-lifecycle branches on them and a default `vi.fn()` would return undefined.
const actual = await vi.importActual<typeof import('./session-store.js')>('./session-store.js')
return {
clearStoredStoreAppSession: vi.fn(),
getCurrentStoredStoreAppSession: vi.fn(),
setStoredStoreAppSession: vi.fn(),
isPreviewStoreSession: actual.isPreviewStoreSession,
sessionKind: actual.sessionKind,
}
})
vi.mock('./token-client.js')

function buildSession(overrides: Partial<StoredStoreAppSession> = {}): StoredStoreAppSession {
Expand Down Expand Up @@ -171,4 +183,62 @@ describe('loadStoredStoreSession', () => {
})
expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42')
})

describe('preview-store sessions', () => {
function buildPreviewSession(overrides: Partial<StoredStoreAppSession> = {}): StoredStoreAppSession {
return {
store: 'preview-1.myshopify.io',
clientId: STORE_AUTH_APP_CLIENT_ID,
userId: 'placeholder:abc',
accessToken: 'shpat_preview_token',
scopes: ['read_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
kind: 'preview',
preview: {
placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
coreUrl: 'https://app.shop.dev',
},
...overrides,
}
}

test('returns the preview session as-is when it has no expiresAt', async () => {
const session = buildPreviewSession()
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session)

await expect(loadStoredStoreSession('preview-1.myshopify.io')).resolves.toEqual(session)
expect(refreshStoreAccessToken).not.toHaveBeenCalled()
})

test('never attempts a PKCE refresh for an expired preview session, even if a refreshToken is somehow present', async () => {
// Defensive: the create-preview path never sets `refreshToken` on a preview session,
// but if one ever sneaks in we still must not hit the OAuth refresh endpoint, which
// the placeholder identity has no relationship with.
const session = buildPreviewSession({
expiresAt: new Date(Date.now() - 60 * 1000).toISOString(),
refreshToken: 'should-be-ignored',
})
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session)

await expect(loadStoredStoreSession('preview-1.myshopify.io')).rejects.toMatchObject({
message: 'Preview store session for preview-1.myshopify.io is no longer valid.',
})
expect(refreshStoreAccessToken).not.toHaveBeenCalled()
})

test('surfaces the preview-specific recovery error rather than the standard re-auth message', async () => {
const session = buildPreviewSession({
expiresAt: new Date(Date.now() - 60 * 1000).toISOString(),
})
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(session)

await expect(loadStoredStoreSession('preview-1.myshopify.io')).rejects.toMatchObject({
tryMessage: expect.stringContaining("Preview store sessions can't be refreshed"),
})
// The PKCE-specific next-step must not surface for preview sessions.
await expect(loadStoredStoreSession('preview-1.myshopify.io')).rejects.not.toMatchObject({
tryMessage: 'To re-authenticate, run:',
})
})
})
})
27 changes: 18 additions & 9 deletions packages/store/src/cli/services/store/auth/session-lifecycle.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import {maskToken} from './config.js'
import {throwStoredStoreAuthError, throwReauthenticateStoreAuthError} from './recovery.js'
import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js'
import {throwStoredStoreAuthError, throwReauthenticateForSession} from './recovery.js'
import {
clearStoredStoreAppSession,
getCurrentStoredStoreAppSession,
isPreviewStoreSession,
setStoredStoreAppSession,
} from './session-store.js'
import {refreshStoreAccessToken} from './token-client.js'
import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output'
import {AbortError} from '@shopify/cli-kit/node/error'
Expand Down Expand Up @@ -56,12 +61,16 @@ export async function loadStoredStoreSession(store: string): Promise<StoredStore
return session
}

// Preview-store sessions can never enter the PKCE refresh flow: they're issued by Core's
// preview-stores orchestrator against a placeholder identity, not by the OAuth token
// endpoint. If a preview session is somehow expired or missing a refresh token, surface
// the preview-specific recovery error directly.
if (isPreviewStoreSession(session)) {
throwReauthenticateForSession(`Preview store session for ${session.store} is no longer valid.`, session)
}

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

outputDebug(
Expand All @@ -80,14 +89,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(','))
throwReauthenticateForSession(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(','))
throwReauthenticateForSession(error.message, session)
}

if (error instanceof AbortError && error.message === 'Received an invalid refresh response from Shopify.') {
Expand Down
Loading
Loading