Skip to content
Merged
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
74 changes: 74 additions & 0 deletions packages/store/src/cli/services/store/auth/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
])
})
})
27 changes: 26 additions & 1 deletion packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -25,6 +25,7 @@ interface StoreAuthDependencies {
waitForStoreAuthCode: typeof waitForStoreAuthCode
exchangeStoreAuthCodeForToken: typeof exchangeStoreAuthCodeForToken
resolveExistingScopes: (store: string) => Promise<ResolvedStoreAuthScopes>
getCurrentStoredStoreAppSession: typeof getCurrentStoredStoreAppSession
presenter: StoreAuthPresenter
}

Expand All @@ -33,6 +34,7 @@ const defaultStoreAuthDependencies: StoreAuthDependencies = {
waitForStoreAuthCode,
exchangeStoreAuthCodeForToken,
resolveExistingScopes: resolveExistingStoreAuthScopes,
getCurrentStoredStoreAppSession,
presenter: createStoreAuthPresenter('text'),
}

Expand All @@ -42,6 +44,9 @@ export async function authenticateStoreWithApp(
): Promise<StoreAuthResult> {
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)
Expand Down Expand Up @@ -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.'],
)
}
Loading