diff --git a/packages/store/src/cli/services/store/auth/index.ts b/packages/store/src/cli/services/store/auth/index.ts index 8856623f96..6cd398dd7f 100644 --- a/packages/store/src/cli/services/store/auth/index.ts +++ b/packages/store/src/cli/services/store/auth/index.ts @@ -13,6 +13,8 @@ import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/out import {AbortError} from '@shopify/cli-kit/node/error' import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +export {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './stored-auth.js' + interface StoreAuthInput { store: string scopes: string diff --git a/packages/store/src/cli/services/store/auth/session-store.ts b/packages/store/src/cli/services/store/auth/session-store.ts index 8e10f730e0..8b34edd6ef 100644 --- a/packages/store/src/cli/services/store/auth/session-store.ts +++ b/packages/store/src/cli/services/store/auth/session-store.ts @@ -1,4 +1,4 @@ -import {storeAuthSessionKey} from './config.js' +import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js' import {LocalStorage} from '@shopify/cli-kit/node/local-storage' export interface StoredStoreAppSession { @@ -87,15 +87,18 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | } } -function readStoredStoreAppSessionBucket( +function sanitizeStoredStoreAppSessionBucket( store: string, + storedBucket: unknown, storage: LocalStorage, ): StoredStoreAppSessionBucket | undefined { - const key = storeAuthSessionKey(store) - const storedBucket = storage.get(key) if (!storedBucket || typeof storedBucket !== 'object') return undefined const {sessionsByUserId, currentUserId} = storedBucket as Partial + const looksLikeBucket = sessionsByUserId !== undefined || currentUserId !== undefined + if (!looksLikeBucket) return undefined + + const key = storeAuthSessionKey(store) if ( !sessionsByUserId || typeof sessionsByUserId !== 'object' || @@ -131,6 +134,61 @@ function readStoredStoreAppSessionBucket( } } +function readStoredStoreAppSessionBucket( + store: string, + storage: LocalStorage, +): StoredStoreAppSessionBucket | undefined { + return sanitizeStoredStoreAppSessionBucket(store, storage.get(storeAuthSessionKey(store)), storage) +} + +// `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): Record { + return ((storage as unknown as {config: {store: Record}}).config.store ?? {}) as Record< + string, + unknown + > +} + +function collectCurrentStoredStoreAppSessions( + storage: LocalStorage, + store: string, + value: unknown, + sessions: StoredStoreAppSession[], +): void { + if (!value || typeof value !== 'object' || Array.isArray(value)) return + + const bucket = sanitizeStoredStoreAppSessionBucket(store, value, storage) + if (bucket) { + const session = bucket.sessionsByUserId[bucket.currentUserId] + if (session) sessions.push(session) + return + } + + for (const [childKey, childValue] of Object.entries(value as Record)) { + collectCurrentStoredStoreAppSessions(storage, `${store}.${childKey}`, childValue, sessions) + } +} + +/** + * Internal persistence helper for projecting the current session for every + * store that has locally stored store auth. + */ +export function listCurrentStoredStoreAppSessions( + storage: LocalStorage = storeSessionStorage(), +): StoredStoreAppSession[] { + const sessions: StoredStoreAppSession[] = [] + const keyPrefix = `${STORE_AUTH_APP_CLIENT_ID}::` + + for (const [key, value] of Object.entries(readRawStoreSessionStorage(storage))) { + if (!key.startsWith(keyPrefix)) continue + collectCurrentStoredStoreAppSessions(storage, key.slice(keyPrefix.length), value, sessions) + } + + return sessions +} + export function getCurrentStoredStoreAppSession( store: string, storage: LocalStorage = storeSessionStorage(), 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 new file mode 100644 index 0000000000..7dd90eddef --- /dev/null +++ b/packages/store/src/cli/services/store/auth/stored-auth.test.ts @@ -0,0 +1,110 @@ +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 {inTemporaryDirectory} from '@shopify/cli-kit/node/fs' +import {LocalStorage} from '@shopify/cli-kit/node/local-storage' +import {describe, expect, test} from 'vitest' + +function buildSession(overrides: Partial = {}): StoredStoreAppSession { + return { + store: 'shop.myshopify.com', + clientId: STORE_AUTH_APP_CLIENT_ID, + userId: '42', + accessToken: 'token-1', + refreshToken: 'refresh-token-1', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + ...overrides, + } +} + +describe('listStoredStoreAuthSummaries', () => { + test('returns an empty array when no store auth is persisted', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + + expect(listStoredStoreAuthSummaries(storage as any)).toEqual([]) + }) + }) + + test('returns one summary per store sorted by store using the current user session', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + + setStoredStoreAppSession(buildSession({store: 'b-shop.myshopify.com'}), storage as any) + setStoredStoreAppSession(buildSession({store: 'a-shop.myshopify.com', userId: '41', accessToken: 'token-41'}), storage as any) + setStoredStoreAppSession(buildSession({store: 'a-shop.myshopify.com', userId: '84', accessToken: 'token-84'}), storage as any) + + expect(listStoredStoreAuthSummaries(storage as any)).toEqual([ + { + store: 'a-shop.myshopify.com', + userId: '84', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + { + store: 'b-shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + }, + ]) + }) + }) + + test('projects associated user metadata without exposing tokens', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + + setStoredStoreAppSession( + buildSession({ + expiresAt: '2026-03-28T00:00:00.000Z', + refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z', + associatedUser: { + id: 42, + email: 'merchant@example.com', + firstName: 'Merchant', + lastName: 'User', + accountOwner: true, + }, + }), + storage as any, + ) + + const [summary] = listStoredStoreAuthSummaries(storage as any) + + expect(summary).toEqual({ + store: 'shop.myshopify.com', + userId: '42', + scopes: ['read_products'], + acquiredAt: '2026-03-27T00:00:00.000Z', + expiresAt: '2026-03-28T00:00:00.000Z', + refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z', + associatedUser: { + id: 42, + email: 'merchant@example.com', + firstName: 'Merchant', + lastName: 'User', + accountOwner: true, + }, + }) + expect(summary).not.toHaveProperty('accessToken') + expect(summary).not.toHaveProperty('refreshToken') + }) + }) + + test('skips malformed persisted buckets while listing summaries', async () => { + await inTemporaryDirectory((cwd) => { + const storage = new LocalStorage>({cwd}) + storage.set(storeAuthSessionKey('broken-shop.myshopify.com'), { + currentUserId: '42', + sessionsByUserId: { + '42': {userId: '42'}, + }, + }) + + expect(listStoredStoreAuthSummaries(storage as any)).toEqual([]) + expect(storage.get(storeAuthSessionKey('broken-shop.myshopify.com'))).toBeUndefined() + }) + }) +}) diff --git a/packages/store/src/cli/services/store/auth/stored-auth.ts b/packages/store/src/cli/services/store/auth/stored-auth.ts new file mode 100644 index 0000000000..f382a788ae --- /dev/null +++ b/packages/store/src/cli/services/store/auth/stored-auth.ts @@ -0,0 +1,27 @@ +import {listCurrentStoredStoreAppSessions, type StoredStoreAppSession} from './session-store.js' + +export interface StoredStoreAuthSummary { + store: string + userId: string + scopes: string[] + acquiredAt: string + expiresAt?: string + refreshTokenExpiresAt?: string + associatedUser?: StoredStoreAppSession['associatedUser'] +} + +type StoreSessionStorage = Parameters[0] + +export function listStoredStoreAuthSummaries(storage?: StoreSessionStorage): StoredStoreAuthSummary[] { + return listCurrentStoredStoreAppSessions(storage) + .map((session) => ({ + store: session.store, + userId: session.userId, + scopes: session.scopes, + acquiredAt: session.acquiredAt, + ...(session.expiresAt ? {expiresAt: session.expiresAt} : {}), + ...(session.refreshTokenExpiresAt ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}), + ...(session.associatedUser ? {associatedUser: session.associatedUser} : {}), + })) + .sort((left, right) => left.store.localeCompare(right.store)) +}