Skip to content
Open
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
2 changes: 2 additions & 0 deletions packages/store/src/cli/services/store/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
63 changes: 59 additions & 4 deletions packages/store/src/cli/services/store/auth/session-store.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -87,15 +87,18 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
}
}

function readStoredStoreAppSessionBucket(
function sanitizeStoredStoreAppSessionBucket(
store: string,
storedBucket: unknown,
storage: LocalStorage<StoreSessionSchema>,
): StoredStoreAppSessionBucket | undefined {
const key = storeAuthSessionKey(store)
const storedBucket = storage.get(key)
if (!storedBucket || typeof storedBucket !== 'object') return undefined

const {sessionsByUserId, currentUserId} = storedBucket as Partial<StoredStoreAppSessionBucket>
const looksLikeBucket = sessionsByUserId !== undefined || currentUserId !== undefined
if (!looksLikeBucket) return undefined

const key = storeAuthSessionKey(store)
if (
!sessionsByUserId ||
typeof sessionsByUserId !== 'object' ||
Expand Down Expand Up @@ -131,6 +134,58 @@ function readStoredStoreAppSessionBucket(
}
}

function readStoredStoreAppSessionBucket(
store: string,
storage: LocalStorage<StoreSessionSchema>,
): 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<StoreSessionSchema>): Record<string, unknown> {
return (storage as unknown as {config: {store: Record<string, unknown>}}).config.store ?? {}
}

function collectCurrentStoredStoreAppSessions(
storage: LocalStorage<StoreSessionSchema>,
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<string, unknown>)) {
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<StoreSessionSchema> = 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<StoreSessionSchema> = storeSessionStorage(),
Expand Down
116 changes: 116 additions & 0 deletions packages/store/src/cli/services/store/auth/stored-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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> = {}): 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<Record<string, unknown>>({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<Record<string, unknown>>({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<Record<string, unknown>>({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<Record<string, unknown>>({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()
})
})
})
27 changes: 27 additions & 0 deletions packages/store/src/cli/services/store/auth/stored-auth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof listCurrentStoredStoreAppSessions>[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))
}
Loading