From 0a2343d036ea7c99a0dcf44713bd0cff38b76c1f Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Fri, 15 May 2026 13:50:10 +0200 Subject: [PATCH 1/2] Add preview-store discriminator to stored store auth sessions Adds a 'kind' discriminator ('standard' | 'preview') and optional 'preview' metadata sub-object to StoredStoreAppSession, plus a recovery dispatcher that surfaces a preview-specific error when a placeholder-owned session can no longer be reached. This is internal scaffolding for the upcoming 'shopify store create preview' command. No user-visible change yet: - Standard sessions written before this field existed read back as 'standard' (sanitizer defaults missing/unknown 'kind' to 'standard' and omits the field on write to keep legacy buckets quiet). - Preview-kind sessions require valid 'preview' metadata (placeholderAccountUuid + coreUrl) or they're rejected by the sanitizer. - session-lifecycle skips the PKCE refresh path entirely for preview sessions and surfaces the preview-specific recovery error. - admin-transport's 401 paths (both versions and the main GraphQL request) route through the new dispatcher so preview sessions don't get told to re-run 'shopify store auth'. The follow-up PR will introduce 'store create preview' itself and start writing 'kind: preview' sessions. --- .../preview-store-session-discriminator.md | 5 + .../cli/services/store/auth/recovery.test.ts | 114 ++++++++++++ .../src/cli/services/store/auth/recovery.ts | 27 +++ .../store/auth/session-lifecycle.test.ts | 72 +++++++- .../services/store/auth/session-lifecycle.ts | 27 ++- .../services/store/auth/session-store.test.ts | 173 ++++++++++++++++++ .../cli/services/store/auth/session-store.ts | 84 +++++++++ .../services/store/execute/admin-transport.ts | 12 +- 8 files changed, 497 insertions(+), 17 deletions(-) create mode 100644 .changeset/preview-store-session-discriminator.md create mode 100644 packages/store/src/cli/services/store/auth/recovery.test.ts diff --git a/.changeset/preview-store-session-discriminator.md b/.changeset/preview-store-session-discriminator.md new file mode 100644 index 00000000000..fe75ec58038 --- /dev/null +++ b/.changeset/preview-store-session-discriminator.md @@ -0,0 +1,5 @@ +--- +'@shopify/store': patch +--- + +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'`. diff --git a/packages/store/src/cli/services/store/auth/recovery.test.ts b/packages/store/src/cli/services/store/auth/recovery.test.ts new file mode 100644 index 00000000000..fac81682865 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/recovery.test.ts @@ -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 { + 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 { + 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:') + } + }) +}) diff --git a/packages/store/src/cli/services/store/auth/recovery.ts b/packages/store/src/cli/services/store/auth/recovery.ts index 8b328701eb0..390276abe13 100644 --- a/packages/store/src/cli/services/store/auth/recovery.ts +++ b/packages/store/src/cli/services/store/auth/recovery.ts @@ -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} { @@ -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( diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts index 2b0b0ee7ee5..451f4371042 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.test.ts @@ -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('./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 { @@ -171,4 +183,62 @@ describe('loadStoredStoreSession', () => { }) expect(clearStoredStoreAppSession).toHaveBeenCalledWith('shop.myshopify.com', '42') }) + + describe('preview-store sessions', () => { + function buildPreviewSession(overrides: Partial = {}): 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:', + }) + }) + }) }) diff --git a/packages/store/src/cli/services/store/auth/session-lifecycle.ts b/packages/store/src/cli/services/store/auth/session-lifecycle.ts index bdefbfd54e4..8b9e578a5a9 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.ts @@ -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' @@ -56,12 +61,16 @@ export async function loadStoredStoreSession(store: string): Promise { expect(storage.get(storeAuthSessionKey('shop.myshopify.com'))).toBeUndefined() }) }) + +function buildPreviewMetadata( + overrides: Partial = {}, +): StoredPreviewStoreMetadata { + return { + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + coreUrl: 'https://app.shop.dev', + ...overrides, + } +} + +function buildPreviewSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'preview-1.myshopify.io', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'placeholder:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + accessToken: 'shpat_preview_token', + scopes: ['read_products', 'write_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + kind: 'preview', + preview: buildPreviewMetadata(), + ...overrides, + } +} + +describe('preview-store discriminator', () => { + test('sessionKind defaults to standard when the discriminator is omitted', () => { + expect(sessionKind(buildSession())).toBe('standard') + }) + + test('sessionKind returns preview when the discriminator is set to preview', () => { + expect(sessionKind(buildPreviewSession())).toBe('preview') + }) + + test('isPreviewStoreSession narrows preview-kind sessions with metadata', () => { + const session = buildPreviewSession() + expect(isPreviewStoreSession(session)).toBe(true) + if (isPreviewStoreSession(session)) { + // The narrowed type makes `preview` non-optional. + expect(session.preview.placeholderAccountUuid).toBe('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa') + } + }) + + test('isPreviewStoreSession returns false for standard sessions', () => { + expect(isPreviewStoreSession(buildSession())).toBe(false) + }) + + test('round-trips a preview-kind session including its metadata', () => { + const storage = inMemoryStorage() + const session = buildPreviewSession({ + preview: buildPreviewMetadata({ + magicLinkUrl: 'https://preview-1.myshopify.io/magic/abc', + magicLinkExpiresAt: '2026-03-27T00:30:00.000Z', + }), + }) + + setStoredStoreAppSession(session, storage as any) + + expect(getCurrentStoredStoreAppSession('preview-1.myshopify.io', storage as any)).toEqual(session) + }) + + test('reads a legacy stored session (without kind) back as a standard session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': buildSession(), + }, + }) + + const loaded = getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)! + + expect(loaded.kind).toBeUndefined() + expect(sessionKind(loaded)).toBe('standard') + expect(isPreviewStoreSession(loaded)).toBe(false) + }) + + test('omits the kind field on disk for standard sessions to keep legacy buckets quiet', () => { + const storage = inMemoryStorage() + setStoredStoreAppSession(buildSession(), storage as any) + + const stored = (storage.get(storeAuthSessionKey('shop.myshopify.com')) as any).sessionsByUserId['42'] + expect(stored).not.toHaveProperty('kind') + expect(stored).not.toHaveProperty('preview') + }) + + test('coerces an unknown kind value to standard and drops it from the result', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': {...buildSession(), kind: 'something-new'}, + }, + }) + + const loaded = getCurrentStoredStoreAppSession('shop.myshopify.com', storage as any)! + expect(loaded.kind).toBeUndefined() + expect(sessionKind(loaded)).toBe('standard') + }) + + test('rejects a preview-kind session that is missing required preview metadata', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('preview-1.myshopify.io'), { + currentUserId: 'placeholder:abc', + sessionsByUserId: { + 'placeholder:abc': { + ...buildPreviewSession({userId: 'placeholder:abc'}), + preview: undefined, + }, + }, + }) + + expect(getCurrentStoredStoreAppSession('preview-1.myshopify.io', storage as any)).toBeUndefined() + }) + + test('rejects a preview-kind session with malformed preview metadata', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('preview-1.myshopify.io'), { + currentUserId: 'placeholder:abc', + sessionsByUserId: { + 'placeholder:abc': { + ...buildPreviewSession({userId: 'placeholder:abc'}), + preview: {placeholderAccountUuid: 123, coreUrl: false}, + }, + }, + }) + + expect(getCurrentStoredStoreAppSession('preview-1.myshopify.io', storage as any)).toBeUndefined() + }) + + test('drops malformed optional preview metadata fields without rejecting the session', () => { + const storage = inMemoryStorage() + storage.set(storeAuthSessionKey('preview-1.myshopify.io'), { + currentUserId: 'placeholder:abc', + sessionsByUserId: { + 'placeholder:abc': { + ...buildPreviewSession({userId: 'placeholder:abc'}), + preview: { + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + coreUrl: 'https://app.shop.dev', + magicLinkUrl: 42, + magicLinkExpiresAt: false, + }, + }, + }, + }) + + const loaded = getCurrentStoredStoreAppSession('preview-1.myshopify.io', storage as any)! + expect(loaded.preview).toEqual({ + placeholderAccountUuid: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + coreUrl: 'https://app.shop.dev', + }) + }) + + test('keeps a standard and a preview session side-by-side under the same store', () => { + const storage = inMemoryStorage() + const standard = buildSession({userId: '42', accessToken: 'token-standard'}) + const preview = buildPreviewSession({ + store: standard.store, + userId: 'placeholder:abc', + accessToken: 'token-preview', + }) + + setStoredStoreAppSession(standard, storage as any) + setStoredStoreAppSession(preview, storage as any) + + // The most recently written session becomes the current one. + expect(getCurrentStoredStoreAppSession(standard.store, storage as any)).toEqual(preview) + }) +}) diff --git a/packages/store/src/cli/services/store/auth/session-store.ts b/packages/store/src/cli/services/store/auth/session-store.ts index 8e10f730e06..01c4e6d2849 100644 --- a/packages/store/src/cli/services/store/auth/session-store.ts +++ b/packages/store/src/cli/services/store/auth/session-store.ts @@ -1,6 +1,34 @@ import {storeAuthSessionKey} from './config.js' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +/** + * Discriminator for a stored store auth session. + * + * - 'standard': created via `shopify store auth` (PKCE OAuth against a real Shopify identity). + * - 'preview': created via `shopify store create preview`. Backed by a placeholder identity, + * holds a shop-scoped Admin API token, has no refresh token, and cannot be + * re-authenticated through the PKCE flow. + * + * Stored sessions written before this discriminator existed have no `kind` field and are + * read back as 'standard'. + */ +// Kept internal for now; re-exported when the `shopify store create preview` command lands. +type StoredStoreSessionKind = 'standard' | 'preview' + +/** + * Preview-store-only metadata. Present iff `kind === 'preview'`. + */ +export interface StoredPreviewStoreMetadata { + /** Placeholder account UUID returned by Core's preview-stores orchestrator. */ + placeholderAccountUuid: string + /** Base URL of the Core orchestrator that minted this session. */ + coreUrl: string + /** One-time-use admin entry URL. Short-lived (~30 min). */ + magicLinkUrl?: string + /** ISO timestamp at which `magicLinkUrl` is expected to stop working. */ + magicLinkExpiresAt?: string +} + export interface StoredStoreAppSession { store: string clientId: string @@ -18,6 +46,35 @@ export interface StoredStoreAppSession { lastName?: string accountOwner?: boolean } + /** + * Discriminator. Optional in storage for back-compat with sessions written before the + * field existed; `sessionKind()` resolves missing values to 'standard'. + */ + kind?: StoredStoreSessionKind + /** Preview-store-only metadata. Set iff `kind === 'preview'`. */ + preview?: StoredPreviewStoreMetadata +} + +/** + * A stored session that has been narrowed to a preview-store session. The `preview` + * metadata is guaranteed to be present. + * + * Kept internal for now; re-exported when the `shopify store create preview` command + * lands and external callers need to construct or pass them around. + */ +type StoredPreviewStoreSession = StoredStoreAppSession & { + kind: 'preview' + preview: StoredPreviewStoreMetadata +} + +/** Resolves the discriminator for a stored session, defaulting to 'standard'. */ +export function sessionKind(session: StoredStoreAppSession): StoredStoreSessionKind { + return session.kind ?? 'standard' +} + +/** Type guard for preview-store-backed sessions. Narrows `preview` to non-optional. */ +export function isPreviewStoreSession(session: StoredStoreAppSession): session is StoredPreviewStoreSession { + return sessionKind(session) === 'preview' && session.preview !== undefined } interface StoredStoreAppSessionBucket { @@ -55,6 +112,20 @@ function sanitizeAssociatedUser(value: unknown): StoredStoreAppSession['associat } } +function sanitizePreviewMetadata(value: unknown): StoredPreviewStoreMetadata | undefined { + if (!value || typeof value !== 'object') return undefined + + const metadata = value as Record + if (!isString(metadata.placeholderAccountUuid) || !isString(metadata.coreUrl)) return undefined + + return { + placeholderAccountUuid: metadata.placeholderAccountUuid, + coreUrl: metadata.coreUrl, + ...(isString(metadata.magicLinkUrl) ? {magicLinkUrl: metadata.magicLinkUrl} : {}), + ...(isString(metadata.magicLinkExpiresAt) ? {magicLinkExpiresAt: metadata.magicLinkExpiresAt} : {}), + } +} + function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | undefined { if (!value || typeof value !== 'object') return undefined @@ -71,6 +142,17 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | return undefined } + // Discriminator is optional for back-compat: sessions written before this field existed + // are read back as 'standard'. Unknown values are coerced to 'standard' and the field is + // omitted from the result so it doesn't pollute legacy buckets. + const kind: StoredStoreSessionKind = session.kind === 'preview' ? 'preview' : 'standard' + const preview = kind === 'preview' ? sanitizePreviewMetadata(session.preview) : undefined + + // A session declared as 'preview' but missing/malformed metadata is rejected outright, + // because downstream code (recovery paths, future re-mint) requires `placeholderAccountUuid` + // and `coreUrl` to act on it. + if (kind === 'preview' && !preview) return undefined + return { store: session.store, clientId: session.clientId, @@ -84,6 +166,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | ...(sanitizeAssociatedUser(session.associatedUser) ? {associatedUser: sanitizeAssociatedUser(session.associatedUser)} : {}), + ...(kind === 'preview' ? {kind} : {}), + ...(preview ? {preview} : {}), } } diff --git a/packages/store/src/cli/services/store/execute/admin-transport.ts b/packages/store/src/cli/services/store/execute/admin-transport.ts index 15cbb480104..8879b3d0a06 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -1,4 +1,4 @@ -import {throwReauthenticateStoreAuthError} from '../auth/recovery.js' +import {throwReauthenticateForSession} from '../auth/recovery.js' import {clearStoredStoreAppSession} from '../auth/session-store.js' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -50,10 +50,9 @@ export async function fetchPublicApiVersions(input: { const status = graphQLClientErrorStatus(error) if (status === 401 || status === 404) { clearStoredStoreAppSession(input.session.store, input.session.userId) - throwReauthenticateStoreAuthError( + throwReauthenticateForSession( `Stored app authentication for ${input.session.store} is no longer valid.`, - input.session.store, - input.session.scopes.join(','), + input.session, ) } @@ -86,10 +85,9 @@ export async function runAdminStoreGraphQLOperation(input: { } catch (error) { if (isGraphQLClientErrorLike(error) && error.response.status === 401) { clearStoredStoreAppSession(input.context.session.store, input.context.session.userId) - throwReauthenticateStoreAuthError( + throwReauthenticateForSession( `Stored app authentication for ${input.context.session.store} is no longer valid.`, - input.context.session.store, - input.context.session.scopes.join(','), + input.context.session, ) } From 83703d3cc94ec6ebf907b91c19af9233862fe97f Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Fri, 15 May 2026 14:58:23 +0200 Subject: [PATCH 2/2] Bump @shopify/store to minor (feature scaffolding, not a fix) --- .changeset/preview-store-session-discriminator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/preview-store-session-discriminator.md b/.changeset/preview-store-session-discriminator.md index fe75ec58038..6bc07fda7e8 100644 --- a/.changeset/preview-store-session-discriminator.md +++ b/.changeset/preview-store-session-discriminator.md @@ -1,5 +1,5 @@ --- -'@shopify/store': patch +'@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'`.