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
5 changes: 5 additions & 0 deletions .changeset/theme-store-auth-opt-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/theme': patch
---

Fix `theme dev` and other theme commands adopting `store auth` sessions with insufficient scopes; only `theme pull` and `theme push` reuse them now.
70 changes: 54 additions & 16 deletions packages/theme/src/cli/utilities/theme-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,12 @@ class TestThemeCommandWithPathFlag extends TestThemeCommandWithForce {
static multiEnvironmentsFlags: RequiredFlags = ['store', 'password', 'path']
}

class TestScopedThemeCommandWithPathFlag extends TestThemeCommandWithPathFlag {
protected storeAuthScopes(): string[] {
return ['read_themes']
}
}

class TestThemeCommandWithUnionFlags extends TestThemeCommand {
static multiEnvironmentsFlags: RequiredFlags = ['store', ['live', 'development', 'theme']]

Expand Down Expand Up @@ -262,12 +268,12 @@ describe('ThemeCommand', () => {
clientId: 'store-auth-client-id',
userId: 'preview:123',
accessToken: 'shpat_preview_token',
scopes: [],
scopes: ['read_themes'],
acquiredAt: '2026-06-08T11:00:00.000Z',
})

await CommandConfig.load()
const command = new TestThemeCommand([], CommandConfig)
const command = new TestScopedThemeCommand([], CommandConfig)

await command.run()

Expand All @@ -284,12 +290,12 @@ describe('ThemeCommand', () => {
clientId: 'store-auth-client-id',
userId: 'preview:123',
accessToken: 'shpat_preview_token',
scopes: [],
scopes: ['read_themes'],
acquiredAt: '2026-06-08T11:00:00.000Z',
})

await CommandConfig.load()
const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig)
const command = new TestScopedThemeCommand(['--password', 'shptka_password'], CommandConfig)

await command.run()

Expand All @@ -300,7 +306,7 @@ describe('ThemeCommand', () => {

test('falls back to theme authentication when no matching store auth cache session exists', async () => {
await CommandConfig.load()
const command = new TestThemeCommand([], CommandConfig)
const command = new TestScopedThemeCommand([], CommandConfig)

await command.run()

Expand All @@ -309,26 +315,24 @@ describe('ThemeCommand', () => {
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
})

test('checks required scopes from the stored session before using a matching store auth cache session', async () => {
test('ignores the store auth cache when the command does not declare store auth scopes', async () => {
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({
store: 'test-store.myshopify.com',
clientId: 'store-auth-client-id',
userId: 'preview:123',
accessToken: 'shpat_preview_token',
scopes: ['read_themes'],
scopes: ['read_themes', 'write_themes'],
acquiredAt: '2026-06-08T11:00:00.000Z',
})

await CommandConfig.load()
const command = new TestScopedThemeCommand([], CommandConfig)
const command = new TestThemeCommand([], CommandConfig)

await command.run()

expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com')
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
expect(command.commandCalls[0]).toMatchObject({
session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'},
})
expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled()
expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined)
expect(command.commandCalls[0]).toMatchObject({session: mockSession})
})

test('treats a matching write scope in the stored session as satisfying a required read scope', async () => {
Expand Down Expand Up @@ -421,7 +425,7 @@ describe('ThemeCommand', () => {
})

await CommandConfig.load()
const command = new TestThemeCommand([], CommandConfig)
const command = new TestScopedThemeCommand([], CommandConfig)

await expect(command.run()).rejects.toThrow('cache read failed')
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
Expand Down Expand Up @@ -1032,7 +1036,7 @@ describe('ThemeCommand', () => {
clientId: 'store-auth-client-id',
userId: 'preview:123',
accessToken: 'shpat_preview_token',
scopes: [],
scopes: ['read_themes'],
acquiredAt: '2026-06-08T11:00:00.000Z',
},
])
Expand All @@ -1046,7 +1050,7 @@ describe('ThemeCommand', () => {
vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store)

await CommandConfig.load()
const command = new TestThemeCommandWithPathFlag(
const command = new TestScopedThemeCommandWithPathFlag(
['--environment', 'preview', '--environment', 'another-preview'],
CommandConfig,
)
Expand All @@ -1068,6 +1072,39 @@ describe('ThemeCommand', () => {
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
vi.mocked(renderConcurrent).mockResolvedValue(undefined)

await CommandConfig.load()
const command = new TestScopedThemeCommandWithPathFlag(
['--environment', 'preview', '--environment', 'another-preview'],
CommandConfig,
)

await command.run()

expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}],
}),
)
expect(renderConcurrent).not.toHaveBeenCalled()
expect(ensureAuthenticatedThemes).not.toHaveBeenCalled()
})

test('multiple environment commands ignore the store auth cache when the command does not declare store auth scopes', async () => {
vi.mocked(loadEnvironment)
.mockResolvedValueOnce({store: 'store1.myshopify.com', path: '/home/path/to/theme1'})
.mockResolvedValueOnce({store: 'store2.myshopify.com', password: 'password2', path: '/home/path/to/theme2'})
vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([
{
store: 'store1.myshopify.com',
clientId: 'store-auth-client-id',
userId: 'preview:123',
accessToken: 'shpat_preview_token',
scopes: ['read_themes'],
acquiredAt: '2026-06-08T11:00:00.000Z',
},
])
vi.mocked(renderConcurrent).mockResolvedValue(undefined)

await CommandConfig.load()
const command = new TestThemeCommandWithPathFlag(
['--environment', 'preview', '--environment', 'another-preview'],
Expand All @@ -1076,6 +1113,7 @@ describe('ThemeCommand', () => {

await command.run()

expect(listCurrentStoredStoreAppSessions).not.toHaveBeenCalled()
expect(renderWarning).toHaveBeenCalledWith(
expect.objectContaining({
body: ['Missing required flags in environment configuration for preview:', {list: {items: ['password']}}],
Expand Down
30 changes: 21 additions & 9 deletions packages/theme/src/cli/utilities/theme-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,13 @@ export default abstract class ThemeCommand extends Command {
await this.runConcurrent(validationResults.valid)
}

protected storeAuthScopes(): string[] {
return []
/**
* Admin API scopes that a stored `store auth` session must include for this
* command to reuse it. Commands opt in to reusing store auth sessions by
* returning the scopes they require; the default opts the command out.
*/
protected storeAuthScopes(): string[] | undefined {
return undefined
}

/**
Expand Down Expand Up @@ -362,6 +367,9 @@ export default abstract class ThemeCommand extends Command {
}

private async storeAuthSessionForTheme(flags: FlagValues): Promise<AdminSession | undefined> {
const requiredScopes = this.storeAuthScopes()
if (!requiredScopes) return undefined

const store = typeof flags.store === 'string' ? flags.store : undefined
const password = flags.password
if (!store || password) return undefined
Expand All @@ -370,10 +378,13 @@ export default abstract class ThemeCommand extends Command {
const storedSession = getCurrentStoredStoreAppSession(storeFqdn)
if (!storedSession) return undefined

return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn)
return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes)
}

private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map<string, AdminSession> {
const requiredScopes = this.storeAuthScopes()
if (!requiredScopes) return new Map()

const stores = new Set(
flagsList
.filter(({store, password}) => typeof store === 'string' && !password)
Expand All @@ -387,7 +398,7 @@ export default abstract class ThemeCommand extends Command {
const storeFqdn = normalizeStoreFqdn(storedSession.store)
if (!stores.has(storeFqdn)) return undefined

const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn)
const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn, requiredScopes)
return session ? ([storeFqdn, session] as const) : undefined
})
.filter((entry): entry is readonly [string, AdminSession] => entry !== undefined),
Expand All @@ -408,8 +419,9 @@ export default abstract class ThemeCommand extends Command {
private adminSessionFromStoreAuthSession(
storedSession: StoredStoreAppSession,
storeFqdn: string,
requiredScopes: string[],
): AdminSession | undefined {
if (!this.hasRequiredStoreAuthScopes(storedSession.scopes)) {
if (!this.hasRequiredStoreAuthScopes(storedSession.scopes, requiredScopes)) {
return undefined
}

Expand All @@ -434,11 +446,11 @@ export default abstract class ThemeCommand extends Command {
return expandedScopes
}

private hasRequiredStoreAuthScopes(scopes: string[]): boolean {
if (scopes.length === 0) return true
private hasRequiredStoreAuthScopes(sessionScopes: string[], requiredScopes: string[]): boolean {
if (sessionScopes.length === 0) return true

const expandedScopes = this.expandImpliedStoreAuthScopes(scopes)
return this.storeAuthScopes().every((scope) => expandedScopes.has(scope))
const expandedScopes = this.expandImpliedStoreAuthScopes(sessionScopes)
return requiredScopes.every((scope) => expandedScopes.has(scope))
}

/**
Expand Down
Loading