diff --git a/packages/store/src/cli/services/store/auth/index.test.ts b/packages/store/src/cli/services/store/auth/index.test.ts index a02c10ce5ac..87ac41f96df 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -605,4 +605,78 @@ describe('store auth service', () => { }), ) }) + + test('authenticateStoreWithApp exits early for preview stores and lists their preapproved scopes', async () => { + const openURL = vi.fn().mockResolvedValue(true) + const waitForStoreAuthCode = vi.fn() + const exchangeStoreAuthCodeForToken = vi.fn() + const presenter = {openingBrowser: vi.fn(), manualAuthUrl: vi.fn(), success: vi.fn()} + const getCurrentStoredStoreAppSessionMock = vi.fn().mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:placeholder-uuid', + accessToken: 'shpat_preview_token', + scopes: ['read_themes', 'write_themes'], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: {shopId: '123', name: 'Lavender Candles', createdAt: '2026-06-08T12:00:00.000Z'}, + }) + + await expect( + authenticateStoreWithApp( + {store: 'shop.myshopify.com', scopes: 'read_products'}, + { + openURL, + waitForStoreAuthCode, + exchangeStoreAuthCodeForToken, + getCurrentStoredStoreAppSession: getCurrentStoredStoreAppSessionMock, + presenter, + }, + ), + ).rejects.toThrow('`store auth` is unavailable for preview stores.') + + // The OAuth flow is never started, and no fqdn metadata is recorded. + expect(openURL).not.toHaveBeenCalled() + expect(waitForStoreAuthCode).not.toHaveBeenCalled() + expect(exchangeStoreAuthCodeForToken).not.toHaveBeenCalled() + expect(presenter.openingBrowser).not.toHaveBeenCalled() + expect(setStoredStoreAppSession).not.toHaveBeenCalled() + expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() + }) + + test('authenticateStoreWithApp surfaces the preapproved scope list in the preview-store error', async () => { + const getCurrentStoredStoreAppSessionMock = vi.fn().mockReturnValue({ + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:placeholder-uuid', + accessToken: 'shpat_preview_token', + scopes: ['read_themes', 'write_themes'], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview', + preview: {shopId: '123', name: 'Lavender Candles', createdAt: '2026-06-08T12:00:00.000Z'}, + }) + + const error = await authenticateStoreWithApp( + {store: 'shop.myshopify.com', scopes: 'read_products'}, + { + openURL: vi.fn(), + waitForStoreAuthCode: vi.fn(), + exchangeStoreAuthCodeForToken: vi.fn(), + getCurrentStoredStoreAppSession: getCurrentStoredStoreAppSessionMock, + presenter: {openingBrowser: vi.fn(), manualAuthUrl: vi.fn(), success: vi.fn()}, + }, + ).then( + () => { + throw new Error('Expected authenticateStoreWithApp to reject for a preview store.') + }, + (err: unknown) => err as Error & {tryMessage?: string; nextSteps?: string[]}, + ) + + expect(error.message).toContain("Additional Admin API scopes can't be granted.") + expect(error.tryMessage).toContain('The following scopes are available: read_themes, write_themes.') + expect(error.tryMessage).toContain('shopify store info --store shop.myshopify.com --json') + expect(error.nextSteps).toEqual([ + 'Run `shopify store execute` directly against the preview store; no `store auth` step is needed.', + ]) + }) }) diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 6cd398dd7f8..37fe47296dd 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -1,5 +1,5 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {setStoredStoreAppSession} from './session-store.js' +import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js' import {exchangeStoreAuthCodeForToken} from './token-client.js' import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' @@ -25,6 +25,7 @@ interface StoreAuthDependencies { waitForStoreAuthCode: typeof waitForStoreAuthCode exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken resolveExistingScopes: (store: string) => Promise + getCurrentStoredStoreAppSession: typeof getCurrentStoredStoreAppSession presenter: StoreAuthPresenter } @@ -33,6 +34,7 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = { waitForStoreAuthCode, exchangeStoreAuthCodeForToken, resolveExistingScopes: resolveExistingStoreAuthScopes, + getCurrentStoredStoreAppSession, presenter: createStoreAuthPresenter('text'), } @@ -42,6 +44,9 @@ export async function authenticateStoreWithApp( ): Promise { const resolvedDependencies: StoreAuthDependencies = {...defaultStoreAuthDependencies, ...dependencies} const store = normalizeStoreFqdn(input.store) + + throwIfPreviewStore(store, resolvedDependencies) + await recordStoreFqdnMetadata(store, false) const requestedScopes = parseStoreAuthScopes(input.scopes) const existingScopeResolution = await resolvedDependencies.resolveExistingScopes(store) @@ -125,3 +130,23 @@ export async function authenticateStoreWithApp( resolvedDependencies.presenter.success(result) return result } + +/** + * Preview stores aren't a logged-in experience, so there's no OAuth flow to run and no way to grant + * additional Admin API scopes after creation. Running the standard `store auth` flow against one + * would silently fail or confuse callers (including agents that habitually run `store auth` before + * `store execute`), so we exit early and point them at the scopes already preapproved at creation. + */ +function throwIfPreviewStore(store: string, dependencies: StoreAuthDependencies): void { + const session = dependencies.getCurrentStoredStoreAppSession(store) + if (session?.kind !== 'preview') return + + const scopes = session.scopes + const scopeList = scopes.length > 0 ? scopes.join(', ') : 'none' + + throw new AbortError( + `\`store auth\` is unavailable for preview stores. Additional Admin API scopes can't be granted.`, + `The following scopes are available: ${scopeList}.\n\nRun \`shopify store info --store ${store} --json\` to view this list again at any time.`, + ['Run `shopify store execute` directly against the preview store; no `store auth` step is needed.'], + ) +}