From 4671bfde4dc2c65b0c8e171f90fd1e1c3abfa49e Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Sun, 28 Jun 2026 19:56:56 +0300 Subject: [PATCH 1/2] Exit `store auth` early for preview stores Preview stores aren't a logged-in experience: there's no OAuth flow and no way to grant additional Admin API scopes after creation. Running the standard store auth flow against one confuses callers (including agents that habitually run store auth before store execute). Detect a cached preview-store session up front and abort with a clear message that store auth is unavailable, listing the scopes already preapproved at creation time and pointing at store execute / store info. Assisted-By: devx/f38d0794-3b14-4a2a-a849-b95ae665f83d --- .../src/cli/services/store/auth/index.test.ts | 69 +++++++++++++++++++ .../src/cli/services/store/auth/index.ts | 30 +++++++- 2 files changed, 98 insertions(+), 1 deletion(-) 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..a12c1104bc0 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,73 @@ 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}, + ) + + expect(error.tryMessage).toContain('read_themes, write_themes') + }) }) diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 6cd398dd7f8..31d1d5a4a41 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,26 @@ 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.`, + `Preview stores aren't a logged-in experience, so additional Admin API scopes can't be granted. The following scopes are already available: ${scopeList}.`, + [ + 'Run `shopify store execute` directly against the preview store; no `store auth` step is needed.', + 'Run `shopify store info --json` to see the preapproved scopes for this store.', + ], + ) +} From 43ccdc45ece8109e43bed522a025aa6b9e3a89a1 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Mon, 29 Jun 2026 19:13:56 +0300 Subject: [PATCH 2/2] Update store auth preview message to new design Assisted-By: devx/7c2ea919-8429-480e-a789-203d7c9c0902 --- packages/store/src/cli/services/store/auth/index.test.ts | 9 +++++++-- packages/store/src/cli/services/store/auth/index.ts | 9 +++------ 2 files changed, 10 insertions(+), 8 deletions(-) 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 a12c1104bc0..87ac41f96df 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -669,9 +669,14 @@ describe('store auth service', () => { () => { throw new Error('Expected authenticateStoreWithApp to reject for a preview store.') }, - (err: unknown) => err as Error & {tryMessage?: string}, + (err: unknown) => err as Error & {tryMessage?: string; nextSteps?: string[]}, ) - expect(error.tryMessage).toContain('read_themes, write_themes') + 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 31d1d5a4a41..37fe47296dd 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -145,11 +145,8 @@ function throwIfPreviewStore(store: string, dependencies: StoreAuthDependencies) const scopeList = scopes.length > 0 ? scopes.join(', ') : 'none' throw new AbortError( - `\`store auth\` is unavailable for preview stores.`, - `Preview stores aren't a logged-in experience, so additional Admin API scopes can't be granted. The following scopes are already available: ${scopeList}.`, - [ - 'Run `shopify store execute` directly against the preview store; no `store auth` step is needed.', - 'Run `shopify store info --json` to see the preapproved scopes for this store.', - ], + `\`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.'], ) }