From 73cebdeb399c14d2dc42f2a20d94c7ae36a0ac90 Mon Sep 17 00:00:00 2001 From: Alfonso Noriega Date: Thu, 11 Jun 2026 11:20:25 +0200 Subject: [PATCH] Use stored store auth scopes for theme auth Assisted-By: devx/2de967b4-f860-4a71-a205-2e8334cf528c --- .changeset/theme-preview-store-auth.md | 7 + packages/cli-kit/src/public/node/constants.ts | 1 + .../public/node/store-auth-session.test.ts} | 89 ++++++- .../src/public/node/store-auth-session.ts} | 138 ++++++++-- .../src/cli/services/store/admin-errors.ts | 4 +- .../services/store/auth/admin-session.test.ts | 47 ++++ .../cli/services/store/auth/admin-session.ts | 23 ++ .../src/cli/services/store/auth/config.ts | 12 +- .../store/auth/existing-scopes.test.ts | 4 +- .../services/store/auth/existing-scopes.ts | 2 +- .../src/cli/services/store/auth/index.test.ts | 4 +- .../src/cli/services/store/auth/index.ts | 2 +- .../store/auth/session-lifecycle.test.ts | 8 +- .../services/store/auth/session-lifecycle.ts | 8 +- .../services/store/auth/stored-auth.test.ts | 2 +- .../cli/services/store/auth/stored-auth.ts | 2 +- .../services/store/create/preview/index.ts | 2 +- .../store/execute/admin-context.test.ts | 48 ++-- .../services/store/execute/admin-context.ts | 14 +- .../store/execute/admin-transport.test.ts | 4 +- .../services/store/execute/admin-transport.ts | 4 +- .../src/cli/services/store/info/index.test.ts | 4 +- .../src/cli/services/store/info/index.ts | 4 +- packages/store/src/index.ts | 2 + packages/theme/src/cli/commands/theme/pull.ts | 4 + packages/theme/src/cli/commands/theme/push.ts | 4 + .../src/cli/utilities/theme-command.test.ts | 252 +++++++++++++++++- .../theme/src/cli/utilities/theme-command.ts | 139 +++++++++- packages/theme/tsconfig.json | 12 +- tsconfig.json | 1 + 30 files changed, 721 insertions(+), 126 deletions(-) create mode 100644 .changeset/theme-preview-store-auth.md create mode 100644 packages/cli-kit/src/public/node/constants.ts rename packages/{store/src/cli/services/store/auth/session-store.test.ts => cli-kit/src/public/node/store-auth-session.test.ts} (69%) rename packages/{store/src/cli/services/store/auth/session-store.ts => cli-kit/src/public/node/store-auth-session.ts} (65%) create mode 100644 packages/store/src/cli/services/store/auth/admin-session.test.ts create mode 100644 packages/store/src/cli/services/store/auth/admin-session.ts diff --git a/.changeset/theme-preview-store-auth.md b/.changeset/theme-preview-store-auth.md new file mode 100644 index 00000000000..2544975e8ef --- /dev/null +++ b/.changeset/theme-preview-store-auth.md @@ -0,0 +1,7 @@ +--- +'@shopify/cli-kit': patch +'@shopify/theme': patch +'@shopify/store': patch +--- + +Allow `shopify theme pull` and `shopify theme push` to authenticate with a matching `shopify store auth` session when no theme password is provided. diff --git a/packages/cli-kit/src/public/node/constants.ts b/packages/cli-kit/src/public/node/constants.ts new file mode 100644 index 00000000000..0b302f39abc --- /dev/null +++ b/packages/cli-kit/src/public/node/constants.ts @@ -0,0 +1 @@ +export const STORE_AUTH_APP_CLIENT_ID = '7e9cb568cfd431c538f36d1ad3f2b4f6' diff --git a/packages/store/src/cli/services/store/auth/session-store.test.ts b/packages/cli-kit/src/public/node/store-auth-session.test.ts similarity index 69% rename from packages/store/src/cli/services/store/auth/session-store.test.ts rename to packages/cli-kit/src/public/node/store-auth-session.test.ts index 72f03398aae..afa209a8b01 100644 --- a/packages/store/src/cli/services/store/auth/session-store.test.ts +++ b/packages/cli-kit/src/public/node/store-auth-session.test.ts @@ -1,12 +1,24 @@ -import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' import { clearStoredStoreAppSession, getCurrentStoredStoreAppSession, + getStoreAuthAdminSession, setStoredStoreAppSession, + storeAuthSessionKey, type StoredStoreAppSession, -} from './session-store.js' -import {describe, test, expect} from 'vitest' -import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +} from './store-auth-session.js' +import {STORE_AUTH_APP_CLIENT_ID} from './constants.js' +import {LocalStorage} from './local-storage.js' +import {inTemporaryDirectory} from './fs.js' +import {setLastSeenUserId} from './session.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./session.js', async () => { + const actual = await vi.importActual('./session.js') + return { + ...actual, + setLastSeenUserId: vi.fn(), + } +}) function inMemoryStorage() { const values = new Map() @@ -36,7 +48,74 @@ function buildSession(overrides: Partial = {}): StoredSto } } -describe('store session storage', () => { +describe('store auth session storage', () => { + beforeEach(() => { + vi.setSystemTime(new Date('2026-06-08T12:00:00.000Z')) + }) + + test('escapes dotted and backslash key segments', () => { + expect(storeAuthSessionKey('shop.myshopify.com\\evil')).toBe( + `${STORE_AUTH_APP_CLIENT_ID}::shop\\.myshopify\\.com\\\\evil`, + ) + }) + + test('returns an Admin session from the current stored store auth session', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + storage.set(storeAuthSessionKey('preview.myshopify.com'), { + currentUserId: 'preview:123', + sessionsByUserId: { + 'preview:123': { + store: 'preview.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:123', + accessToken: 'shpat_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + }, + }) + + expect(getStoreAuthAdminSession('https://preview.myshopify.com/admin', storage as any)).toEqual({ + token: 'shpat_token', + storeFqdn: 'preview.myshopify.com', + }) + expect(setLastSeenUserId).toHaveBeenCalledWith('preview:123') + }) + }) + + test('returns undefined when no stored store auth session exists', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + + expect(getStoreAuthAdminSession('preview.myshopify.com', storage as any)).toBeUndefined() + expect(setLastSeenUserId).not.toHaveBeenCalled() + }) + }) + + test('returns undefined when the current stored store auth session is expired', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + storage.set(storeAuthSessionKey('preview.myshopify.com'), { + currentUserId: 'preview:123', + sessionsByUserId: { + 'preview:123': { + store: 'preview.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: 'preview:123', + accessToken: 'shpat_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + expiresAt: '2026-06-08T11:30:00.000Z', + }, + }, + }) + + expect(getStoreAuthAdminSession('preview.myshopify.com', storage as any)).toBeUndefined() + expect(setLastSeenUserId).not.toHaveBeenCalled() + }) + }) + test('returns the current user session for a store', () => { const storage = inMemoryStorage() diff --git a/packages/store/src/cli/services/store/auth/session-store.ts b/packages/cli-kit/src/public/node/store-auth-session.ts similarity index 65% rename from packages/store/src/cli/services/store/auth/session-store.ts rename to packages/cli-kit/src/public/node/store-auth-session.ts index 0acdb46232a..430d1fc35b7 100644 --- a/packages/store/src/cli/services/store/auth/session-store.ts +++ b/packages/cli-kit/src/public/node/store-auth-session.ts @@ -1,6 +1,12 @@ -import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' -import {LocalStorage} from '@shopify/cli-kit/node/local-storage' -import {type JsonMapType} from '@shopify/cli-kit/node/toml' +import {STORE_AUTH_APP_CLIENT_ID} from './constants.js' +import {normalizeStoreFqdn} from './context/fqdn.js' +import {LocalStorage} from './local-storage.js' +import {setLastSeenUserId} from './session.js' +import type {JsonMapType} from './toml/codec.js' +import type {AdminSession} from './session.js' + +const STORE_AUTH_PROJECT_NAME = 'shopify-cli-store' +const EXPIRY_MARGIN_MS = 4 * 60 * 1000 /** * Discriminator for a stored store auth session. @@ -11,9 +17,9 @@ import {type JsonMapType} from '@shopify/cli-kit/node/toml' * Stored sessions written before this discriminator existed have no `kind` field and are * read back as 'standard'. */ -type StoredStoreSessionKind = 'standard' | 'preview' +export type StoredStoreSessionKind = 'standard' | 'preview' -interface StoredPreviewStoreMetadata { +export interface StoredPreviewStoreMetadata { /** Placeholder account UUID returned by the preview-store backend when available. */ placeholderAccountUuid?: string /** Numeric shop id returned by the preview-store backend. */ @@ -59,17 +65,31 @@ interface StoredStoreAppSessionBucket { sessionsByUserId: {[userId: string]: StoredStoreAppSession} } -interface StoreSessionSchema { +interface StoreAuthSessionSchema { [key: string]: StoredStoreAppSessionBucket } -type RawStoreSessionStorage = JsonMapType +type RawStoreAuthSessionStorage = JsonMapType -let _storeSessionStorage: LocalStorage | undefined +let _storeAuthSessionStorage: LocalStorage | undefined -function storeSessionStorage() { - _storeSessionStorage ??= new LocalStorage({projectName: 'shopify-cli-store'}) - return _storeSessionStorage +function storeAuthSessionStorage() { + _storeAuthSessionStorage ??= new LocalStorage({projectName: STORE_AUTH_PROJECT_NAME}) + return _storeAuthSessionStorage +} + +/** + * Build the local-storage key used for store-auth sessions. + * + * @param store - The normalized store FQDN. + * @returns The store-auth session storage key. + */ +export function storeAuthSessionKey(store: string): string { + return `${STORE_AUTH_APP_CLIENT_ID}::${escapeStoreAuthSessionKeySegment(store)}` +} + +function escapeStoreAuthSessionKeySegment(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\./g, '\\.') } function isString(value: unknown): value is string { @@ -154,7 +174,7 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | function sanitizeStoredStoreAppSessionBucket( store: string, storedBucket: unknown, - storage: LocalStorage, + storage: LocalStorage, ): StoredStoreAppSessionBucket | undefined { if (!storedBucket || typeof storedBucket !== 'object') return undefined @@ -200,7 +220,7 @@ function sanitizeStoredStoreAppSessionBucket( function readStoredStoreAppSessionBucket( store: string, - storage: LocalStorage, + storage: LocalStorage, ): StoredStoreAppSessionBucket | undefined { return sanitizeStoredStoreAppSessionBucket(store, storage.get(storeAuthSessionKey(store)), storage) } @@ -208,21 +228,23 @@ function readStoredStoreAppSessionBucket( // `conf` persists dotted keys as nested objects. Store-auth callers should not // learn that layout directly; this helper keeps the current traversal private to // the persistence seam while higher-level code projects summaries instead. -function readRawStoreSessionStorage(storage: LocalStorage): RawStoreSessionStorage { - return (storage as unknown as {config?: {store?: RawStoreSessionStorage}}).config?.store ?? {} +function readRawStoreAuthSessionStorage(storage: LocalStorage): RawStoreAuthSessionStorage { + return (storage as unknown as {config?: {store?: RawStoreAuthSessionStorage}}).config?.store ?? {} } /** - * Internal persistence helper for projecting the current session for every - * store that has locally stored store auth. + * Project the current session for every store that has locally stored store auth. + * + * @param storage - Optional storage override for tests. + * @returns Current stored store auth sessions. */ export function listCurrentStoredStoreAppSessions( - storage: LocalStorage = storeSessionStorage(), + storage: LocalStorage = storeAuthSessionStorage(), ): StoredStoreAppSession[] { const sessions: StoredStoreAppSession[] = [] const keyPrefix = `${STORE_AUTH_APP_CLIENT_ID}::` - for (const [key, value] of Object.entries(readRawStoreSessionStorage(storage))) { + for (const [key, value] of Object.entries(readRawStoreAuthSessionStorage(storage))) { if (!key.startsWith(keyPrefix)) continue const bucket = sanitizeStoredStoreAppSessionBucket(key.slice(keyPrefix.length), value, storage) @@ -233,25 +255,39 @@ export function listCurrentStoredStoreAppSessions( return sessions } +/** + * Get the current stored store auth session for a store. + * + * @param store - The store FQDN or URL to load a store-auth session for. + * @param storage - Optional storage override for tests. + * @returns The current stored store auth session, or undefined when missing or malformed. + */ export function getCurrentStoredStoreAppSession( store: string, - storage: LocalStorage = storeSessionStorage(), + storage: LocalStorage = storeAuthSessionStorage(), ): StoredStoreAppSession | undefined { - const bucket = readStoredStoreAppSessionBucket(store, storage) + const storeFqdn = normalizeStoreFqdn(store) + const bucket = readStoredStoreAppSessionBucket(storeFqdn, storage) if (!bucket) return undefined const session = bucket.sessionsByUserId[bucket.currentUserId] if (!session) { - storage.delete(storeAuthSessionKey(store)) + storage.delete(storeAuthSessionKey(storeFqdn)) return undefined } return session } +/** + * Persist a store auth session and mark it as current for its store. + * + * @param session - The store auth session to persist. + * @param storage - Optional storage override for tests. + */ export function setStoredStoreAppSession( session: StoredStoreAppSession, - storage: LocalStorage = storeSessionStorage(), + storage: LocalStorage = storeAuthSessionStorage(), ): void { const key = storeAuthSessionKey(session.store) const existingBucket = readStoredStoreAppSessionBucket(session.store, storage) @@ -267,22 +303,30 @@ export function setStoredStoreAppSession( storage.set(key, nextBucket) } +/** + * Clear stored store auth sessions for a store. + * + * @param store - The store FQDN or URL to clear sessions for. + * @param userIdOrStorage - Optional user ID to clear, or storage override when clearing all users. + * @param maybeStorage - Optional storage override when clearing one user. + */ export function clearStoredStoreAppSession( store: string, - userIdOrStorage?: string | LocalStorage, - maybeStorage?: LocalStorage, + userIdOrStorage?: string | LocalStorage, + maybeStorage?: LocalStorage, ): void { + const storeFqdn = normalizeStoreFqdn(store) const userId = typeof userIdOrStorage === 'string' ? userIdOrStorage : undefined - const storage = (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeSessionStorage() + const storage = (typeof userIdOrStorage === 'string' ? maybeStorage : userIdOrStorage) ?? storeAuthSessionStorage() - const key = storeAuthSessionKey(store) + const key = storeAuthSessionKey(storeFqdn) if (!userId) { storage.delete(key) return } - const existingBucket = readStoredStoreAppSessionBucket(store, storage) + const existingBucket = readStoredStoreAppSessionBucket(storeFqdn, storage) if (!existingBucket) return const {[userId]: _removedSession, ...remainingSessions} = existingBucket.sessionsByUserId @@ -298,3 +342,41 @@ export function clearStoredStoreAppSession( sessionsByUserId: remainingSessions, }) } + +/** + * Load an Admin API session from the local store-auth cache when one is currently usable. + * + * @param store - The store FQDN or URL to load a store-auth session for. + * @param storage - Optional storage override for tests. + * @returns An Admin session, or undefined when no usable session is cached. + */ +export function getStoreAuthAdminSession( + store: string, + storage: LocalStorage = storeAuthSessionStorage(), +): AdminSession | undefined { + const storeFqdn = normalizeStoreFqdn(store) + const session = getCurrentStoredStoreAppSession(storeFqdn, storage) + if (!session || isSessionExpired(session)) return undefined + + setLastSeenUserId(session.userId) + + return { + token: session.accessToken, + storeFqdn, + } +} + +/** + * Check whether a stored store auth session is expired, including the refresh margin. + * + * @param session - The stored store auth session. + * @returns True when the session is expired or has an invalid expiry timestamp. + */ +export function isSessionExpired(session: StoredStoreAppSession): boolean { + if (!session.expiresAt) return false + + const expiresAtMs = new Date(session.expiresAt).getTime() + if (Number.isNaN(expiresAtMs)) return true + + return expiresAtMs - EXPIRY_MARGIN_MS < Date.now() +} diff --git a/packages/store/src/cli/services/store/admin-errors.ts b/packages/store/src/cli/services/store/admin-errors.ts index e9b2cb072d6..4faa86e6149 100644 --- a/packages/store/src/cli/services/store/admin-errors.ts +++ b/packages/store/src/cli/services/store/admin-errors.ts @@ -1,7 +1,7 @@ import {throwReauthenticateStoreAuthError} from './auth/recovery.js' -import {clearStoredStoreAppSession} from './auth/session-store.js' +import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' -import type {StoredStoreAppSession} from './auth/session-store.js' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' interface GraphQLClientErrorLike { response: {status?: number; errors?: unknown} diff --git a/packages/store/src/cli/services/store/auth/admin-session.test.ts b/packages/store/src/cli/services/store/auth/admin-session.test.ts new file mode 100644 index 00000000000..35773362d74 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/admin-session.test.ts @@ -0,0 +1,47 @@ +import {loadAdminSessionFromStoreAuth} from './admin-session.js' +import {loadStoredStoreSession} from './session-lifecycle.js' +import {recordStoreFqdnMetadata} from '../attribution.js' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./session-lifecycle.js') +vi.mock('../attribution.js') +vi.mock('@shopify/cli-kit/node/session') + +describe('loadAdminSessionFromStoreAuth', () => { + test('returns an Admin session from a matching stored store auth session', async () => { + const storedSession = { + store: 'preview.myshopify.com', + clientId: 'client-id', + userId: 'preview:123', + accessToken: 'shpat_token', + scopes: [], + acquiredAt: '2026-06-08T12:00:00.000Z', + kind: 'preview' as const, + preview: { + shopId: '123', + name: 'Preview Store', + createdAt: '2026-06-08T12:00:00.000Z', + }, + } + vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + + const got = await loadAdminSessionFromStoreAuth('https://preview.myshopify.com/admin') + + expect(loadStoredStoreSession).toHaveBeenCalledWith('preview.myshopify.com') + expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('preview.myshopify.com', true) + expect(setLastSeenUserId).toHaveBeenCalledWith('preview:123') + expect(got).toEqual({ + adminSession: {token: 'shpat_token', storeFqdn: 'preview.myshopify.com'}, + session: storedSession, + }) + }) + + test('propagates store auth cache errors', async () => { + vi.mocked(loadStoredStoreSession).mockRejectedValue(new Error('missing session')) + + await expect(loadAdminSessionFromStoreAuth('preview.myshopify.com')).rejects.toThrow('missing session') + expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() + expect(setLastSeenUserId).not.toHaveBeenCalled() + }) +}) diff --git a/packages/store/src/cli/services/store/auth/admin-session.ts b/packages/store/src/cli/services/store/auth/admin-session.ts new file mode 100644 index 00000000000..e83239eb946 --- /dev/null +++ b/packages/store/src/cli/services/store/auth/admin-session.ts @@ -0,0 +1,23 @@ +import {loadStoredStoreSession} from './session-lifecycle.js' +import {recordStoreFqdnMetadata} from '../attribution.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {setLastSeenUserId} from '@shopify/cli-kit/node/session' +import type {AdminSession} from '@shopify/cli-kit/node/session' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' + +export async function loadAdminSessionFromStoreAuth(store: string): Promise<{ + adminSession: AdminSession + session: StoredStoreAppSession +}> { + const session = await loadStoredStoreSession(normalizeStoreFqdn(store)) + await recordStoreFqdnMetadata(session.store, true) + setLastSeenUserId(session.userId) + + return { + adminSession: { + token: session.accessToken, + storeFqdn: session.store, + }, + session, + } +} diff --git a/packages/store/src/cli/services/store/auth/config.ts b/packages/store/src/cli/services/store/auth/config.ts index 46b72e6a7da..5a4739cda7f 100644 --- a/packages/store/src/cli/services/store/auth/config.ts +++ b/packages/store/src/cli/services/store/auth/config.ts @@ -1,4 +1,6 @@ -export const STORE_AUTH_APP_CLIENT_ID = '7e9cb568cfd431c538f36d1ad3f2b4f6' +export {STORE_AUTH_APP_CLIENT_ID} from '@shopify/cli-kit/node/constants' +export {storeAuthSessionKey} from '@shopify/cli-kit/node/store-auth-session' + export const DEFAULT_STORE_AUTH_PORT = 13387 export const STORE_AUTH_CALLBACK_PATH = '/auth/callback' @@ -6,14 +8,6 @@ export function storeAuthRedirectUri(port: number): string { return `http://127.0.0.1:${port}${STORE_AUTH_CALLBACK_PATH}` } -export function storeAuthSessionKey(store: string): string { - return `${STORE_AUTH_APP_CLIENT_ID}::${escapeStoreAuthSessionKeySegment(store)}` -} - -function escapeStoreAuthSessionKeySegment(value: string): string { - return value.replace(/\./g, '\\.') -} - export function maskToken(token: string): string { if (token.length <= 10) return '***' return `${token.slice(0, 10)}***` diff --git a/packages/store/src/cli/services/store/auth/existing-scopes.test.ts b/packages/store/src/cli/services/store/auth/existing-scopes.test.ts index 2ea04aab008..7b8aa38ada0 100644 --- a/packages/store/src/cli/services/store/auth/existing-scopes.test.ts +++ b/packages/store/src/cli/services/store/auth/existing-scopes.test.ts @@ -1,13 +1,13 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {resolveExistingStoreAuthScopes} from './existing-scopes.js' import {loadStoredStoreSession} from './session-lifecycle.js' -import {getCurrentStoredStoreAppSession} from './session-store.js' +import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -vi.mock('./session-store.js') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('./session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/api/admin', async () => { diff --git a/packages/store/src/cli/services/store/auth/existing-scopes.ts b/packages/store/src/cli/services/store/auth/existing-scopes.ts index 4eaeb13ef2a..6ecbacb188f 100644 --- a/packages/store/src/cli/services/store/auth/existing-scopes.ts +++ b/packages/store/src/cli/services/store/auth/existing-scopes.ts @@ -1,6 +1,6 @@ -import {getCurrentStoredStoreAppSession} from './session-store.js' import {loadStoredStoreSession} from './session-lifecycle.js' import {fetchCurrentStoreAuthScopes} from './token-client.js' +import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' 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 87ac41f96df..41faaf966b0 100644 --- a/packages/store/src/cli/services/store/auth/index.test.ts +++ b/packages/store/src/cli/services/store/auth/index.test.ts @@ -1,11 +1,11 @@ import {authenticateStoreWithApp} from './index.js' -import {setStoredStoreAppSession} from './session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import {recordStoreFqdnMetadata} from '../attribution.js' +import {setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {describe, expect, test, vi} from 'vitest' -vi.mock('./session-store.js') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('../attribution.js') vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/system', () => ({openURL: vi.fn().mockResolvedValue(true)})) diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 37fe47296dd..d9676f3ec34 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -1,5 +1,4 @@ import {STORE_AUTH_APP_CLIENT_ID} from './config.js' -import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js' import {exchangeStoreAuthCodeForToken} from './token-client.js' import {waitForStoreAuthCode} from './callback.js' import {createPkceBootstrap} from './pkce.js' @@ -7,6 +6,7 @@ import {mergeRequestedAndStoredScopes, parseStoreAuthScopes, resolveGrantedScope import {resolveExistingStoreAuthScopes, type ResolvedStoreAuthScopes} from './existing-scopes.js' import {createStoreAuthPresenter, type StoreAuthPresenter, type StoreAuthResult} from './result.js' import {recordStoreFqdnMetadata} from '../attribution.js' +import {getCurrentStoredStoreAppSession, setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {openURL} from '@shopify/cli-kit/node/system' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' 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..739b7b2f0ad 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 @@ -1,16 +1,16 @@ import {isSessionExpired, loadStoredStoreSession} from './session-lifecycle.js' +import {refreshStoreAccessToken} from './token-client.js' +import {STORE_AUTH_APP_CLIENT_ID} from './config.js' import { clearStoredStoreAppSession, getCurrentStoredStoreAppSession, setStoredStoreAppSession, type StoredStoreAppSession, -} from './session-store.js' -import {refreshStoreAccessToken} from './token-client.js' -import {STORE_AUTH_APP_CLIENT_ID} from './config.js' +} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import {describe, expect, test, vi} from 'vitest' -vi.mock('./session-store.js') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('./token-client.js') function buildSession(overrides: Partial = {}): StoredStoreAppSession { 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..a3c4dcf101d 100644 --- a/packages/store/src/cli/services/store/auth/session-lifecycle.ts +++ b/packages/store/src/cli/services/store/auth/session-lifecycle.ts @@ -1,10 +1,14 @@ import {maskToken} from './config.js' import {throwStoredStoreAuthError, throwReauthenticateStoreAuthError} from './recovery.js' -import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession, setStoredStoreAppSession} from './session-store.js' import {refreshStoreAccessToken} from './token-client.js' +import { + clearStoredStoreAppSession, + getCurrentStoredStoreAppSession, + setStoredStoreAppSession, +} from '@shopify/cli-kit/node/store-auth-session' import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' -import type {StoredStoreAppSession} from './session-store.js' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' const EXPIRY_MARGIN_MS = 4 * 60 * 1000 diff --git a/packages/store/src/cli/services/store/auth/stored-auth.test.ts b/packages/store/src/cli/services/store/auth/stored-auth.test.ts index c2d1a0f960f..7831c8e10ff 100644 --- a/packages/store/src/cli/services/store/auth/stored-auth.test.ts +++ b/packages/store/src/cli/services/store/auth/stored-auth.test.ts @@ -1,6 +1,6 @@ import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' -import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js' import {listStoredStoreAuthSummaries} from './stored-auth.js' +import {setStoredStoreAppSession, type StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' import {describe, expect, test} from 'vitest' diff --git a/packages/store/src/cli/services/store/auth/stored-auth.ts b/packages/store/src/cli/services/store/auth/stored-auth.ts index 12caa540dff..64e147a5acf 100644 --- a/packages/store/src/cli/services/store/auth/stored-auth.ts +++ b/packages/store/src/cli/services/store/auth/stored-auth.ts @@ -1,4 +1,4 @@ -import {listCurrentStoredStoreAppSessions, type StoredStoreAppSession} from './session-store.js' +import {listCurrentStoredStoreAppSessions, type StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' export interface StoredStoreAuthSummary { store: string diff --git a/packages/store/src/cli/services/store/create/preview/index.ts b/packages/store/src/cli/services/store/create/preview/index.ts index b311fd6d9a4..204f7dee870 100644 --- a/packages/store/src/cli/services/store/create/preview/index.ts +++ b/packages/store/src/cli/services/store/create/preview/index.ts @@ -1,7 +1,7 @@ import {PreviewStoreClientOptions, PreviewStoreCreateResponse, createPreviewStore} from './client.js' import {STORE_AUTH_APP_CLIENT_ID} from '../../auth/config.js' -import {setStoredStoreAppSession} from '../../auth/session-store.js' import {recordStoreFqdnMetadata} from '../../attribution.js' +import {setStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' export const PREVIEW_USER_ID_PREFIX = 'preview:' diff --git a/packages/store/src/cli/services/store/execute/admin-context.test.ts b/packages/store/src/cli/services/store/execute/admin-context.test.ts index cb64d86180f..0d6d3d42597 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.test.ts @@ -1,15 +1,11 @@ import {prepareAdminStoreGraphQLContext} from './admin-context.js' import {fetchPublicApiVersions} from './admin-transport.js' -import {loadStoredStoreSession} from '../auth/session-lifecycle.js' +import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' -import {recordStoreFqdnMetadata} from '../attribution.js' import {AbortError} from '@shopify/cli-kit/node/error' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {beforeEach, describe, expect, test, vi} from 'vitest' -vi.mock('../auth/session-lifecycle.js', () => ({loadStoredStoreSession: vi.fn()})) -vi.mock('../attribution.js') -vi.mock('@shopify/cli-kit/node/session') +vi.mock('../auth/admin-session.js') vi.mock('./admin-transport.js', () => ({ fetchPublicApiVersions: vi.fn(), // runAdminStoreGraphQLOperation isn't exercised here, but we re-export it for type completeness. @@ -29,7 +25,10 @@ describe('prepareAdminStoreGraphQLContext', () => { } beforeEach(() => { - vi.mocked(loadStoredStoreSession).mockResolvedValue(storedSession) + vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({ + adminSession: {token: storedSession.accessToken, storeFqdn: storedSession.store}, + session: storedSession, + }) vi.mocked(fetchPublicApiVersions).mockResolvedValue([ {handle: '2025-10', supported: true}, {handle: '2025-07', supported: true}, @@ -40,10 +39,7 @@ describe('prepareAdminStoreGraphQLContext', () => { test('returns the stored admin session, version, and full auth session', async () => { const result = await prepareAdminStoreGraphQLContext({store}) - expect(loadStoredStoreSession).toHaveBeenCalledWith(store) - expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce() - expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true) - expect(setLastSeenUserId).toHaveBeenCalledWith('42') + expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store) expect(fetchPublicApiVersions).toHaveBeenCalledWith({ adminSession: {token: 'token', storeFqdn: store}, session: storedSession, @@ -62,7 +58,10 @@ describe('prepareAdminStoreGraphQLContext', () => { refreshToken: 'fresh-refresh-token', expiresAt: '2026-04-03T00:00:00.000Z', } - vi.mocked(loadStoredStoreSession).mockResolvedValue(refreshedSession) + vi.mocked(loadAdminSessionFromStoreAuth).mockResolvedValue({ + adminSession: {token: refreshedSession.accessToken, storeFqdn: refreshedSession.store}, + session: refreshedSession, + }) const result = await prepareAdminStoreGraphQLContext({store}) @@ -83,9 +82,7 @@ describe('prepareAdminStoreGraphQLContext', () => { test('allows unstable without consulting the transport, but still loads the stored session', async () => { const result = await prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: 'unstable'}) - expect(loadStoredStoreSession).toHaveBeenCalledWith(store) - expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce() - expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true) + expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store) expect(result).toEqual({ adminSession: {token: 'token', storeFqdn: store}, version: 'unstable', @@ -98,32 +95,21 @@ describe('prepareAdminStoreGraphQLContext', () => { await expect(prepareAdminStoreGraphQLContext({store, userSpecifiedVersion: '1999-01'})).rejects.toThrow( 'Invalid API version', ) - expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce() - expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true) + expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store) }) - test('does not record validated store metadata when loading stored auth fails', async () => { - vi.mocked(loadStoredStoreSession).mockRejectedValue(new AbortError('missing stored auth')) + test('does not resolve API versions when loading stored auth fails', async () => { + vi.mocked(loadAdminSessionFromStoreAuth).mockRejectedValue(new AbortError('missing stored auth')) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('missing stored auth') - expect(recordStoreFqdnMetadata).not.toHaveBeenCalled() + expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store) expect(fetchPublicApiVersions).not.toHaveBeenCalled() }) - test('re-records fqdn metadata when the stored session store differs from the requested store', async () => { - vi.mocked(loadStoredStoreSession).mockResolvedValue({...storedSession, store: 'permanent-shop.myshopify.com'}) - - await prepareAdminStoreGraphQLContext({store}) - - expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce() - expect(recordStoreFqdnMetadata).toHaveBeenCalledWith('permanent-shop.myshopify.com', true) - }) - test('rethrows whatever the transport raises (errors are owned by the transport)', async () => { vi.mocked(fetchPublicApiVersions).mockRejectedValue(new AbortError('upstream exploded')) await expect(prepareAdminStoreGraphQLContext({store})).rejects.toThrow('upstream exploded') - expect(recordStoreFqdnMetadata).toHaveBeenCalledOnce() - expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(store, true) + expect(loadAdminSessionFromStoreAuth).toHaveBeenCalledWith(store) }) }) diff --git a/packages/store/src/cli/services/store/execute/admin-context.ts b/packages/store/src/cli/services/store/execute/admin-context.ts index 0eddff703fb..475ef51bcdd 100644 --- a/packages/store/src/cli/services/store/execute/admin-context.ts +++ b/packages/store/src/cli/services/store/execute/admin-context.ts @@ -1,10 +1,8 @@ import {fetchPublicApiVersions} from './admin-transport.js' -import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {recordStoreFqdnMetadata} from '../attribution.js' +import {loadAdminSessionFromStoreAuth} from '../auth/admin-session.js' import {AbortError} from '@shopify/cli-kit/node/error' -import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import type {AdminSession} from '@shopify/cli-kit/node/session' -import type {StoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' export interface AdminStoreGraphQLContext { adminSession: AdminSession @@ -38,13 +36,7 @@ export async function prepareAdminStoreGraphQLContext(input: { store: string userSpecifiedVersion?: string }): Promise { - const session = await loadStoredStoreSession(input.store) - await recordStoreFqdnMetadata(session.store, true) - setLastSeenUserId(session.userId) - const adminSession = { - token: session.accessToken, - storeFqdn: session.store, - } + const {adminSession, session} = await loadAdminSessionFromStoreAuth(input.store) const version = await resolveApiVersion({session, adminSession, userSpecifiedVersion: input.userSpecifiedVersion}) return {adminSession, version, session} diff --git a/packages/store/src/cli/services/store/execute/admin-transport.test.ts b/packages/store/src/cli/services/store/execute/admin-transport.test.ts index ea1fc2a70f6..2e2f5a3e478 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.test.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.test.ts @@ -4,15 +4,15 @@ import { fetchPublicApiVersions, runAdminStoreGraphQLOperation, } from './admin-transport.js' -import {clearStoredStoreAppSession} from '../auth/session-store.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' +import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {beforeEach, describe, expect, test, vi} from 'vitest' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {renderSingleTask} from '@shopify/cli-kit/node/ui' -vi.mock('../auth/session-store.js') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('@shopify/cli-kit/node/api/graphql') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/api/admin', async () => { 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 a893dff08a5..8b24b546322 100644 --- a/packages/store/src/cli/services/store/execute/admin-transport.ts +++ b/packages/store/src/cli/services/store/execute/admin-transport.ts @@ -5,7 +5,7 @@ import { throwIfStoredStoreAuthIsInvalid, ABORTED_FETCH_MESSAGE_FRAGMENTS, } from '../admin-errors.js' -import {clearStoredStoreAppSession} from '../auth/session-store.js' +import {clearStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' import {AbortError} from '@shopify/cli-kit/node/error' @@ -14,7 +14,7 @@ import {renderSingleTask} from '@shopify/cli-kit/node/ui' import type {AdminSession} from '@shopify/cli-kit/node/session' import type {PreparedStoreExecuteRequest} from './request.js' import type {AdminStoreGraphQLContext} from './admin-context.js' -import type {StoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' export {ABORTED_FETCH_MESSAGE_FRAGMENTS} diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 64620ecf27f..a9182610be3 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -3,9 +3,9 @@ import {StoreInfoBusinessPlatformStoreNotFoundError, fetchDestinationsContext} f import {fetchOrganizationShop} from './organization-shop.js' import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '../auth/session-store.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {getPreviewStore} from '../create/preview/client.js' +import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError, BugError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -23,7 +23,7 @@ vi.mock('./destinations.js', async () => { }) vi.mock('./organization-shop.js') vi.mock('../auth/session-lifecycle.js') -vi.mock('../auth/session-store.js') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('../attribution.js') vi.mock('../create/preview/client.js') vi.mock('@shopify/cli-kit/node/api/graphql') diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 264fb1e0173..09ef2002549 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -4,9 +4,9 @@ import {mapPlanToPublicHandle} from './plan.js' import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-errors.js' import {recordStoreFqdnMetadata} from '../attribution.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' -import {getCurrentStoredStoreAppSession} from '../auth/session-store.js' import {getPreviewStore} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' +import {getCurrentStoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -15,7 +15,7 @@ import {extractMyshopifyHandle} from '@shopify/cli-kit/common/url' import {setLastSeenUserId} from '@shopify/cli-kit/node/session' import {outputDebug} from '@shopify/cli-kit/node/output' import type {DestinationsContext, OrganizationShopFields, StoreInfoResult, StoreInfoStoreOwner} from './types.js' -import type {StoredStoreAppSession} from '../auth/session-store.js' +import type {StoredStoreAppSession} from '@shopify/cli-kit/node/store-auth-session' interface GetStoreInfoOptions { store?: string diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 7a0951b8ff1..1b989133e3a 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -9,6 +9,8 @@ import StoreExecute from './cli/commands/store/execute.js' import StoreInfo from './cli/commands/store/info.js' import StoreList from './cli/commands/store/list.js' +export {loadAdminSessionFromStoreAuth} from './cli/services/store/auth/admin-session.js' + const COMMANDS = { 'store:auth:list': StoreAuthList, 'store:auth': StoreAuth, diff --git a/packages/theme/src/cli/commands/theme/pull.ts b/packages/theme/src/cli/commands/theme/pull.ts index 960709e6e32..aa61f4af01d 100644 --- a/packages/theme/src/cli/commands/theme/pull.ts +++ b/packages/theme/src/cli/commands/theme/pull.ts @@ -65,4 +65,8 @@ If no theme is specified, then you're prompted to select the theme to pull from await pull({...flags, noColor: flags['no-color']}, adminSession, multiEnvironment, context) recordTiming('theme-command:pull') } + + protected storeAuthScopes(): string[] { + return ['read_themes'] + } } diff --git a/packages/theme/src/cli/commands/theme/push.ts b/packages/theme/src/cli/commands/theme/push.ts index f1e954541c3..7fe6c584cc1 100644 --- a/packages/theme/src/cli/commands/theme/push.ts +++ b/packages/theme/src/cli/commands/theme/push.ts @@ -137,4 +137,8 @@ export default class Push extends ThemeCommand { ) recordTiming('theme-command:push') } + + protected storeAuthScopes(): string[] { + return ['read_themes', 'write_themes'] + } } diff --git a/packages/theme/src/cli/utilities/theme-command.test.ts b/packages/theme/src/cli/utilities/theme-command.test.ts index 22f6a3698a1..616e8563d78 100644 --- a/packages/theme/src/cli/utilities/theme-command.test.ts +++ b/packages/theme/src/cli/utilities/theme-command.test.ts @@ -3,9 +3,12 @@ import {ensureThemeStore} from './theme-store.js' import {describe, vi, expect, test, beforeEach} from 'vitest' import {Config, Flags} from '@oclif/core' import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import { + getCurrentStoredStoreAppSession, + listCurrentStoredStoreAppSessions, +} from '@shopify/cli-kit/node/store-auth-session' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import {fileExistsSync} from '@shopify/cli-kit/node/fs' -import {AbortError} from '@shopify/cli-kit/node/error' import {resolvePath} from '@shopify/cli-kit/node/path' import {renderConcurrent, renderConfirmationPrompt, renderError, renderWarning} from '@shopify/cli-kit/node/ui' import {addPublicMetadata, addSensitiveMetadata} from '@shopify/cli-kit/node/metadata' @@ -14,6 +17,7 @@ import {hashString} from '@shopify/cli-kit/node/crypto' import type {Writable} from 'stream' vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/store-auth-session') vi.mock('@shopify/cli-kit/node/environments') vi.mock('@shopify/cli-kit/node/ui') vi.mock('@shopify/cli-kit/node/metadata', () => ({ @@ -67,6 +71,12 @@ class TestThemeCommand extends ThemeCommand { } } +class TestScopedThemeCommand extends TestThemeCommand { + protected storeAuthScopes(): string[] { + return ['read_themes'] + } +} + class TestThemeCommandWithForce extends TestThemeCommand { static flags = { ...TestThemeCommand.flags, @@ -180,6 +190,8 @@ describe('ThemeCommand', () => { } vi.mocked(ensureThemeStore).mockReturnValue('test-store.myshopify.com') vi.mocked(ensureAuthenticatedThemes).mockResolvedValue(mockSession) + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue(undefined) + vi.mocked(listCurrentStoredStoreAppSessions).mockReturnValue([]) vi.mocked(fileExistsSync).mockReturnValue(true) }) @@ -244,6 +256,177 @@ describe('ThemeCommand', () => { expect(sensitiveMetadata).toContainEqual({store_fqdn: mockSession.storeFqdn}) }) + test('uses a matching store auth cache session when no password is provided', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + 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'}, + }) + }) + + test('uses the password flag instead of a matching store auth cache session', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestThemeCommand(['--password', 'shptka_password'], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', 'shptka_password') + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('falls back to theme authentication when no matching store auth cache session exists', async () => { + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('checks required scopes from the stored session before using a matching store auth cache session', 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'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], 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'}, + }) + }) + + test('treats a matching write scope in the stored session as satisfying a required read scope', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: ['write_themes'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('uses a matching store auth cache session when stored scopes are empty', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockReturnValue({ + store: 'test-store.myshopify.com', + clientId: 'store-auth-client-id', + userId: 'preview:123', + accessToken: 'shpat_preview_token', + scopes: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('falls back to theme authentication when matching store auth session lacks required 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_products'], + acquiredAt: '2026-06-08T11:00:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(getCurrentStoredStoreAppSession).toHaveBeenCalledWith('test-store.myshopify.com') + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('test-store.myshopify.com', undefined) + expect(command.commandCalls[0]).toMatchObject({session: mockSession}) + }) + + test('does not check stored store auth cache session expiry', 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'], + acquiredAt: '2026-06-08T11:00:00.000Z', + expiresAt: '2026-06-08T11:30:00.000Z', + }) + + await CommandConfig.load() + const command = new TestScopedThemeCommand([], CommandConfig) + + await command.run() + + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + expect(command.commandCalls[0]).toMatchObject({ + session: {token: 'shpat_preview_token', storeFqdn: 'test-store.myshopify.com'}, + }) + }) + + test('rethrows unexpected store auth cache errors', async () => { + vi.mocked(getCurrentStoredStoreAppSession).mockImplementationOnce(() => { + throw new Error('cache read failed') + }) + + await CommandConfig.load() + const command = new TestThemeCommand([], CommandConfig) + + await expect(command.run()).rejects.toThrow('cache read failed') + expect(ensureAuthenticatedThemes).not.toHaveBeenCalled() + }) + test('single environment provided but not found in TOML - throws AbortError', async () => { // Given vi.mocked(loadEnvironment).mockResolvedValue(undefined) @@ -252,7 +435,6 @@ describe('ThemeCommand', () => { const command = new TestThemeCommand(['--environment', 'notreal'], CommandConfig) // When/Then - await expect(command.run()).rejects.toThrow(AbortError) await expect(command.run()).rejects.toThrow('Please provide a valid environment.') }) @@ -383,7 +565,7 @@ describe('ThemeCommand', () => { vi.mocked(fileExistsSync).mockReturnValue(false) - await expect(command.run()).rejects.toThrow(AbortError) + await expect(command.run()).rejects.toThrow('Path does not exist: current/working/directory') expect(fileExistsSync).toHaveBeenCalledWith('current/working/directory') }) @@ -839,6 +1021,70 @@ describe('ThemeCommand', () => { expect(liveEnvFlags?.['no-color']).toEqual(true) }) + test('multiple environment commands accept missing password when a store auth cache session exists', async () => { + const storeAuthSession = {token: 'shpat_preview_token', storeFqdn: 'store1.myshopify.com'} + 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: [], + acquiredAt: '2026-06-08T11:00:00.000Z', + }, + ]) + vi.mocked(renderConfirmationPrompt).mockResolvedValue(true) + vi.mocked(renderConcurrent).mockImplementation(async ({processes}) => { + for (const process of processes) { + // eslint-disable-next-line no-await-in-loop + await process.action({} as Writable, {} as Writable, {} as any) + } + }) + vi.mocked(ensureThemeStore).mockImplementation((options: any) => options.store) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--environment', 'preview', '--environment', 'another-preview'], + CommandConfig, + ) + + await command.run() + + expect(renderWarning).not.toHaveBeenCalled() + expect(listCurrentStoredStoreAppSessions).toHaveBeenCalledOnce() + expect(getCurrentStoredStoreAppSession).not.toHaveBeenCalled() + expect(ensureAuthenticatedThemes).toHaveBeenCalledWith('store2.myshopify.com', 'password2') + expect(command.commandCalls).toEqual( + expect.arrayContaining([expect.objectContaining({session: storeAuthSession})]), + ) + }) + + test('multiple environment commands still require password when no store auth cache session exists', 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(renderConcurrent).mockResolvedValue(undefined) + + await CommandConfig.load() + const command = new TestThemeCommandWithPathFlag( + ['--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('commands will only create a session object if the password flag is supported', async () => { // Given vi.mocked(loadEnvironment) diff --git a/packages/theme/src/cli/utilities/theme-command.ts b/packages/theme/src/cli/utilities/theme-command.ts index e80f625972e..66872dabcbc 100644 --- a/packages/theme/src/cli/utilities/theme-command.ts +++ b/packages/theme/src/cli/utilities/theme-command.ts @@ -5,7 +5,12 @@ import {useThemeStoreContext} from '../services/local-storage.js' import {hashString} from '@shopify/cli-kit/node/crypto' import {Input} from '@oclif/core/interfaces' import Command, {ArgOutput, FlagOutput, noDefaultsOptions} from '@shopify/cli-kit/node/base-command' -import {AdminSession, ensureAuthenticatedThemes} from '@shopify/cli-kit/node/session' +import {AdminSession, ensureAuthenticatedThemes, setLastSeenUserId} from '@shopify/cli-kit/node/session' +import { + getCurrentStoredStoreAppSession, + listCurrentStoredStoreAppSessions, + type StoredStoreAppSession, +} from '@shopify/cli-kit/node/store-auth-session' import {loadEnvironment} from '@shopify/cli-kit/node/environments' import { renderWarning, @@ -29,6 +34,7 @@ interface ValidEnvironment { environment: EnvironmentName flags: FlagValues requiresAuth: boolean + storeAuthSession?: AdminSession } type EnvironmentName = string /** @@ -130,6 +136,10 @@ export default abstract class ThemeCommand extends Command { await this.runConcurrent(validationResults.valid) } + protected storeAuthScopes(): string[] { + return [] + } + /** * Create a map of environments from the shopify.theme.toml file * @param environments - Names of environments to load @@ -184,14 +194,27 @@ export default abstract class ThemeCommand extends Command { const valid: ValidEnvironment[] = [] const invalid: {environment: EnvironmentName; reason: string}[] = [] - for (const [environmentName, {flags, validationFlags}] of environmentMap) { - const validationResult = this.validConfig(validationFlags, requiredFlags, environmentName) + const storeAuthSessionsByStore = requiresAuth + ? this.storeAuthSessionsForTheme(Array.from(environmentMap.values()).map(({validationFlags}) => validationFlags)) + : new Map() + + const entriesWithStoreAuthSessions = Array.from(environmentMap.entries()).map( + ([environmentName, {flags, validationFlags}]) => ({ + environmentName, + flags, + validationFlags, + storeAuthSession: this.storeAuthSessionFromCache(validationFlags, storeAuthSessionsByStore), + }), + ) + + for (const {environmentName, flags, validationFlags, storeAuthSession} of entriesWithStoreAuthSessions) { + const validationResult = this.validConfig(validationFlags, requiredFlags, environmentName, storeAuthSession) if (validationResult !== true) { const missingFlagsText = validationResult.join(', ') invalid.push({environment: environmentName, reason: `Missing flags: ${missingFlagsText}`}) continue } - valid.push({environment: environmentName, flags, requiresAuth}) + valid.push({environment: environmentName, flags, requiresAuth, storeAuthSession}) } return {valid, invalid} @@ -267,13 +290,13 @@ export default abstract class ThemeCommand extends Command { for (const runGroup of runGroups) { // eslint-disable-next-line no-await-in-loop await renderConcurrent({ - processes: runGroup.map(({environment, flags, requiresAuth}) => ({ + processes: runGroup.map(({environment, flags, requiresAuth, storeAuthSession}) => ({ prefix: environment, action: async (stdout: Writable, stderr: Writable, _signal) => { try { const store = flags.store as string await useThemeStoreContext(store, async () => { - const session = requiresAuth ? await this.createSession(flags) : undefined + const session = requiresAuth ? await this.createSession(flags, storeAuthSession) : undefined const commandName = this.constructor.name.toLowerCase() recordEvent(`theme-command:${commandName}:multi-env:authenticated`) @@ -323,14 +346,98 @@ export default abstract class ThemeCommand extends Command { * @param flags - The environment flags containing store and password * @returns The unauthenticated session object */ - private async createSession(flags: FlagValues) { - const store = flags.store as string - const password = flags.password as string - const session = await ensureAuthenticatedThemes(ensureThemeStore({store}), password) + private async createSession(flags: FlagValues, storeAuthSession?: AdminSession) { + const store = ensureThemeStore({store: flags.store as string | undefined}) + const password = flags.password as string | undefined + const session = password + ? await ensureAuthenticatedThemes(store, password) + : (storeAuthSession ?? + (await this.storeAuthSessionForTheme({store})) ?? + (await ensureAuthenticatedThemes(store, password))) return session } + private async storeAuthSessionForTheme(flags: FlagValues): Promise { + const store = typeof flags.store === 'string' ? flags.store : undefined + const password = flags.password + if (!store || password) return undefined + + const storeFqdn = normalizeStoreFqdn(store) + const storedSession = getCurrentStoredStoreAppSession(storeFqdn) + if (!storedSession) return undefined + + return this.adminSessionFromStoreAuthSession(storedSession, storeFqdn) + } + + private storeAuthSessionsForTheme(flagsList: FlagValues[]): Map { + const stores = new Set( + flagsList + .filter(({store, password}) => typeof store === 'string' && !password) + .map(({store}) => normalizeStoreFqdn(store as string)), + ) + if (stores.size === 0) return new Map() + + return new Map( + listCurrentStoredStoreAppSessions() + .map((storedSession) => { + const storeFqdn = normalizeStoreFqdn(storedSession.store) + if (!stores.has(storeFqdn)) return undefined + + const session = this.adminSessionFromStoreAuthSession(storedSession, storeFqdn) + return session ? ([storeFqdn, session] as const) : undefined + }) + .filter((entry): entry is readonly [string, AdminSession] => entry !== undefined), + ) + } + + private storeAuthSessionFromCache( + flags: FlagValues, + storeAuthSessionsByStore: Map, + ): AdminSession | undefined { + const store = typeof flags.store === 'string' ? flags.store : undefined + const password = flags.password + if (!store || password) return undefined + + return storeAuthSessionsByStore.get(normalizeStoreFqdn(store)) + } + + private adminSessionFromStoreAuthSession( + storedSession: StoredStoreAppSession, + storeFqdn: string, + ): AdminSession | undefined { + if (!this.hasRequiredStoreAuthScopes(storedSession.scopes)) { + return undefined + } + + setLastSeenUserId(storedSession.userId) + + return { + token: storedSession.accessToken, + storeFqdn, + } + } + + private expandImpliedStoreAuthScopes(scopes: string[]): Set { + const expandedScopes = new Set(scopes) + + for (const scope of scopes) { + const matches = scope.match(/^(unauthenticated_)?write_(.*)$/) + if (matches) { + expandedScopes.add(`${matches[1] ?? ''}read_${matches[2]}`) + } + } + + return expandedScopes + } + + private hasRequiredStoreAuthScopes(scopes: string[]): boolean { + if (scopes.length === 0) return true + + const expandedScopes = this.expandImpliedStoreAuthScopes(scopes) + return this.storeAuthScopes().every((scope) => expandedScopes.has(scope)) + } + /** * Ensure that all required flags are present * @param environmentFlags - The environment flags @@ -342,9 +449,14 @@ export default abstract class ThemeCommand extends Command { environmentFlags: FlagValues, requiredFlags: Exclude, environmentName: string, + storeAuthSession?: AdminSession, ): string[] | true { const missingFlags = requiredFlags - .filter((flag) => (Array.isArray(flag) ? !flag.some((flag) => environmentFlags[flag]) : !environmentFlags[flag])) + .filter((flag) => + Array.isArray(flag) + ? !flag.some((flag) => this.hasRequiredFlag(environmentFlags, flag, storeAuthSession)) + : !this.hasRequiredFlag(environmentFlags, flag, storeAuthSession), + ) .map((flag) => (Array.isArray(flag) ? flag.join(' or ') : flag)) if (missingFlags.length > 0) { @@ -360,6 +472,11 @@ export default abstract class ThemeCommand extends Command { return true } + private hasRequiredFlag(environmentFlags: FlagValues, flag: string, storeAuthSession?: AdminSession): boolean { + if (flag === 'password' && storeAuthSession) return true + return Boolean(environmentFlags[flag]) + } + /** * Error if the --path flag is provided via CLI when running a multi environment command * Commands that act on local files require each environment to specify its own path in the shopify.theme.toml diff --git a/packages/theme/tsconfig.json b/packages/theme/tsconfig.json index ea7490fa22f..84276bda1e4 100644 --- a/packages/theme/tsconfig.json +++ b/packages/theme/tsconfig.json @@ -1,13 +1,19 @@ { "extends": "../../configurations/tsconfig.json", - "include": ["./src/**/*.ts"], - "exclude": ["./dist"], + "include": [ + "./src/**/*.ts" + ], + "exclude": [ + "./dist" + ], "compilerOptions": { "outDir": "dist", "rootDir": "src", "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo" }, "references": [ - {"path": "../cli-kit"} + { + "path": "../cli-kit" + } ] } diff --git a/tsconfig.json b/tsconfig.json index d9d10a325e7..3a4094dc89e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ {"path": "./packages/cli"}, {"path": "./packages/app"}, {"path": "./packages/theme"}, + {"path": "./packages/store"}, {"path": "./packages/cli-kit"}, {"path": "./packages/create-app"}, {"path": "./packages/ui-extensions-server-kit"},