Skip to content
Draft
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
69 changes: 62 additions & 7 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 @@ -71,6 +71,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
return undefined
}

const associatedUser = sanitizeAssociatedUser(session.associatedUser)

return {
store: session.store,
clientId: session.clientId,
Expand All @@ -81,21 +83,22 @@ 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} : {}),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice drive-by refactor 👍

}
}

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> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do these need to be unknowns? Would a union type be possible?

return (storage as unknown as {config: {store: Record<string, unknown>}}).config.store ?? {}
}

function collectCurrentStoredStoreAppSessions(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is kind of an abomination. I would strongly prefer that we have a background task that fixes the storage once (e.g. foo.myshopify.com => foo-myshopify-com) and then we can avoid this complexity going forward.

By the way, we don't really test this crawling logic anywhere if I am understanding correctly.

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
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