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
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
72 changes: 65 additions & 7 deletions packages/store/src/cli/services/store/auth/session-store.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {storeAuthSessionKey} from './config.js'
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'

/**
* Discriminator for a stored store auth session.
Expand Down Expand Up @@ -62,6 +63,8 @@ interface StoreSessionSchema {
[key: string]: StoredStoreAppSessionBucket
}

type RawStoreSessionStorage = JsonMapType

let _storeSessionStorage: LocalStorage<StoreSessionSchema> | undefined

function storeSessionStorage() {
Expand Down Expand Up @@ -120,6 +123,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
return undefined
}

const associatedUser = sanitizeAssociatedUser(session.associatedUser)

// Discriminator is optional for back-compat: sessions written before this field existed
// are read back as 'standard'. Unknown values are coerced to 'standard' and the field is
// omitted from the result so it doesn't pollute legacy buckets.
Expand All @@ -140,23 +145,24 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
...(isString(session.refreshToken) ? {refreshToken: session.refreshToken} : {}),
...(isString(session.expiresAt) ? {expiresAt: session.expiresAt} : {}),
...(isString(session.refreshTokenExpiresAt) ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}),
...(sanitizeAssociatedUser(session.associatedUser)
? {associatedUser: sanitizeAssociatedUser(session.associatedUser)}
: {}),
...(associatedUser ? {associatedUser} : {}),
Comment thread
alfonso-noriega marked this conversation as resolved.
...(kind === 'preview' ? {kind} : {}),
...(preview ? {preview} : {}),
}
}

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 @@ -192,6 +198,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>): RawStoreSessionStorage {
return (storage as unknown as {config?: {store?: RawStoreSessionStorage}}).config?.store ?? {}
}

function collectCurrentStoredStoreAppSessions(
Comment thread
alfonso-noriega marked this conversation as resolved.
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 RawStoreSessionStorage)) {
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
163 changes: 163 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,163 @@
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 newest auth using the current user session', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<Record<string, unknown>>({cwd})

setStoredStoreAppSession(
buildSession({store: 'b-shop.myshopify.com', acquiredAt: '2026-03-27T00:00:00.000Z'}),
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',
acquiredAt: '2026-03-28T00:00:00.000Z',
}),
storage as any,
)

expect(listStoredStoreAuthSummaries(storage as any)).toEqual([
{
store: 'a-shop.myshopify.com',
userId: '84',
scopes: ['read_products'],
acquiredAt: '2026-03-28T00:00:00.000Z',
},
{
store: 'b-shop.myshopify.com',
userId: '42',
scopes: ['read_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
},
])
})
})

test('sorts stores alphabetically when auth timestamps match', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<Record<string, unknown>>({cwd})
const acquiredAt = '2026-03-27T00:00:00.000Z'

setStoredStoreAppSession(buildSession({store: 'b-shop.myshopify.com', acquiredAt}), storage as any)
setStoredStoreAppSession(buildSession({store: 'a-shop.myshopify.com', acquiredAt}), storage as any)

expect(listStoredStoreAuthSummaries(storage as any).map((summary) => summary.store)).toEqual([
'a-shop.myshopify.com',
'b-shop.myshopify.com',
])
})
})

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('drops malformed sibling sessions while preserving the current session', async () => {
await inTemporaryDirectory((cwd) => {
const storage = new LocalStorage<Record<string, unknown>>({cwd})
const key = storeAuthSessionKey('shop.myshopify.com')
storage.set(key, {
currentUserId: '42',
sessionsByUserId: {
'41': {userId: '41'},
'42': buildSession(),
},
})

expect(listStoredStoreAuthSummaries(storage as any)).toEqual([
{
store: 'shop.myshopify.com',
userId: '42',
scopes: ['read_products'],
acquiredAt: '2026-03-27T00:00:00.000Z',
},
])
expect(Object.keys((storage.get(key) as any).sessionsByUserId)).toEqual(['42'])
})
})

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) => right.acquiredAt.localeCompare(left.acquiredAt) || left.store.localeCompare(right.store))
}
Loading