Skip to content

Commit 52daf1c

Browse files
committed
Introduce local store auth summary API
Adds a read-only API that projects the current session for every store with locally stored `store auth`, for use by later store list sources.
1 parent 6fbe857 commit 52daf1c

4 files changed

Lines changed: 201 additions & 4 deletions

File tree

packages/store/src/cli/services/store/auth/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import {outputContent, outputDebug, outputToken} from '@shopify/cli-kit/node/out
1313
import {AbortError} from '@shopify/cli-kit/node/error'
1414
import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn'
1515

16+
export {listStoredStoreAuthSummaries, type StoredStoreAuthSummary} from './stored-auth.js'
17+
1618
interface StoreAuthInput {
1719
store: string
1820
scopes: string

packages/store/src/cli/services/store/auth/session-store.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {storeAuthSessionKey} from './config.js'
1+
import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js'
22
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
33

44
export interface StoredStoreAppSession {
@@ -87,15 +87,18 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession |
8787
}
8888
}
8989

90-
function readStoredStoreAppSessionBucket(
90+
function sanitizeStoredStoreAppSessionBucket(
9191
store: string,
92+
storedBucket: unknown,
9293
storage: LocalStorage<StoreSessionSchema>,
9394
): StoredStoreAppSessionBucket | undefined {
94-
const key = storeAuthSessionKey(store)
95-
const storedBucket = storage.get(key)
9695
if (!storedBucket || typeof storedBucket !== 'object') return undefined
9796

9897
const {sessionsByUserId, currentUserId} = storedBucket as Partial<StoredStoreAppSessionBucket>
98+
const looksLikeBucket = sessionsByUserId !== undefined || currentUserId !== undefined
99+
if (!looksLikeBucket) return undefined
100+
101+
const key = storeAuthSessionKey(store)
99102
if (
100103
!sessionsByUserId ||
101104
typeof sessionsByUserId !== 'object' ||
@@ -131,6 +134,61 @@ function readStoredStoreAppSessionBucket(
131134
}
132135
}
133136

137+
function readStoredStoreAppSessionBucket(
138+
store: string,
139+
storage: LocalStorage<StoreSessionSchema>,
140+
): StoredStoreAppSessionBucket | undefined {
141+
return sanitizeStoredStoreAppSessionBucket(store, storage.get(storeAuthSessionKey(store)), storage)
142+
}
143+
144+
// `conf` persists dotted keys as nested objects. Store-auth callers should not
145+
// learn that layout directly; this helper keeps the current traversal private to
146+
// the persistence seam while higher-level code projects summaries instead.
147+
function readRawStoreSessionStorage(storage: LocalStorage<StoreSessionSchema>): Record<string, unknown> {
148+
return ((storage as unknown as {config: {store: Record<string, unknown>}}).config.store ?? {}) as Record<
149+
string,
150+
unknown
151+
>
152+
}
153+
154+
function collectCurrentStoredStoreAppSessions(
155+
storage: LocalStorage<StoreSessionSchema>,
156+
store: string,
157+
value: unknown,
158+
sessions: StoredStoreAppSession[],
159+
): void {
160+
if (!value || typeof value !== 'object' || Array.isArray(value)) return
161+
162+
const bucket = sanitizeStoredStoreAppSessionBucket(store, value, storage)
163+
if (bucket) {
164+
const session = bucket.sessionsByUserId[bucket.currentUserId]
165+
if (session) sessions.push(session)
166+
return
167+
}
168+
169+
for (const [childKey, childValue] of Object.entries(value as Record<string, unknown>)) {
170+
collectCurrentStoredStoreAppSessions(storage, `${store}.${childKey}`, childValue, sessions)
171+
}
172+
}
173+
174+
/**
175+
* Internal persistence helper for projecting the current session for every
176+
* store that has locally stored store auth.
177+
*/
178+
export function listCurrentStoredStoreAppSessions(
179+
storage: LocalStorage<StoreSessionSchema> = storeSessionStorage(),
180+
): StoredStoreAppSession[] {
181+
const sessions: StoredStoreAppSession[] = []
182+
const keyPrefix = `${STORE_AUTH_APP_CLIENT_ID}::`
183+
184+
for (const [key, value] of Object.entries(readRawStoreSessionStorage(storage))) {
185+
if (!key.startsWith(keyPrefix)) continue
186+
collectCurrentStoredStoreAppSessions(storage, key.slice(keyPrefix.length), value, sessions)
187+
}
188+
189+
return sessions
190+
}
191+
134192
export function getCurrentStoredStoreAppSession(
135193
store: string,
136194
storage: LocalStorage<StoreSessionSchema> = storeSessionStorage(),
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import {STORE_AUTH_APP_CLIENT_ID, storeAuthSessionKey} from './config.js'
2+
import {setStoredStoreAppSession, type StoredStoreAppSession} from './session-store.js'
3+
import {listStoredStoreAuthSummaries} from './stored-auth.js'
4+
import {inTemporaryDirectory} from '@shopify/cli-kit/node/fs'
5+
import {LocalStorage} from '@shopify/cli-kit/node/local-storage'
6+
import {describe, expect, test} from 'vitest'
7+
8+
function buildSession(overrides: Partial<StoredStoreAppSession> = {}): StoredStoreAppSession {
9+
return {
10+
store: 'shop.myshopify.com',
11+
clientId: STORE_AUTH_APP_CLIENT_ID,
12+
userId: '42',
13+
accessToken: 'token-1',
14+
refreshToken: 'refresh-token-1',
15+
scopes: ['read_products'],
16+
acquiredAt: '2026-03-27T00:00:00.000Z',
17+
...overrides,
18+
}
19+
}
20+
21+
describe('listStoredStoreAuthSummaries', () => {
22+
test('returns an empty array when no store auth is persisted', async () => {
23+
await inTemporaryDirectory((cwd) => {
24+
const storage = new LocalStorage<Record<string, unknown>>({cwd})
25+
26+
expect(listStoredStoreAuthSummaries(storage as any)).toEqual([])
27+
})
28+
})
29+
30+
test('returns one summary per store sorted by store using the current user session', async () => {
31+
await inTemporaryDirectory((cwd) => {
32+
const storage = new LocalStorage<Record<string, unknown>>({cwd})
33+
34+
setStoredStoreAppSession(buildSession({store: 'b-shop.myshopify.com'}), storage as any)
35+
setStoredStoreAppSession(buildSession({store: 'a-shop.myshopify.com', userId: '41', accessToken: 'token-41'}), storage as any)
36+
setStoredStoreAppSession(buildSession({store: 'a-shop.myshopify.com', userId: '84', accessToken: 'token-84'}), storage as any)
37+
38+
expect(listStoredStoreAuthSummaries(storage as any)).toEqual([
39+
{
40+
store: 'a-shop.myshopify.com',
41+
userId: '84',
42+
scopes: ['read_products'],
43+
acquiredAt: '2026-03-27T00:00:00.000Z',
44+
},
45+
{
46+
store: 'b-shop.myshopify.com',
47+
userId: '42',
48+
scopes: ['read_products'],
49+
acquiredAt: '2026-03-27T00:00:00.000Z',
50+
},
51+
])
52+
})
53+
})
54+
55+
test('projects associated user metadata without exposing tokens', async () => {
56+
await inTemporaryDirectory((cwd) => {
57+
const storage = new LocalStorage<Record<string, unknown>>({cwd})
58+
59+
setStoredStoreAppSession(
60+
buildSession({
61+
expiresAt: '2026-03-28T00:00:00.000Z',
62+
refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z',
63+
associatedUser: {
64+
id: 42,
65+
email: 'merchant@example.com',
66+
firstName: 'Merchant',
67+
lastName: 'User',
68+
accountOwner: true,
69+
},
70+
}),
71+
storage as any,
72+
)
73+
74+
const [summary] = listStoredStoreAuthSummaries(storage as any)
75+
76+
expect(summary).toEqual({
77+
store: 'shop.myshopify.com',
78+
userId: '42',
79+
scopes: ['read_products'],
80+
acquiredAt: '2026-03-27T00:00:00.000Z',
81+
expiresAt: '2026-03-28T00:00:00.000Z',
82+
refreshTokenExpiresAt: '2026-04-28T00:00:00.000Z',
83+
associatedUser: {
84+
id: 42,
85+
email: 'merchant@example.com',
86+
firstName: 'Merchant',
87+
lastName: 'User',
88+
accountOwner: true,
89+
},
90+
})
91+
expect(summary).not.toHaveProperty('accessToken')
92+
expect(summary).not.toHaveProperty('refreshToken')
93+
})
94+
})
95+
96+
test('skips malformed persisted buckets while listing summaries', async () => {
97+
await inTemporaryDirectory((cwd) => {
98+
const storage = new LocalStorage<Record<string, unknown>>({cwd})
99+
storage.set(storeAuthSessionKey('broken-shop.myshopify.com'), {
100+
currentUserId: '42',
101+
sessionsByUserId: {
102+
'42': {userId: '42'},
103+
},
104+
})
105+
106+
expect(listStoredStoreAuthSummaries(storage as any)).toEqual([])
107+
expect(storage.get(storeAuthSessionKey('broken-shop.myshopify.com'))).toBeUndefined()
108+
})
109+
})
110+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {listCurrentStoredStoreAppSessions, type StoredStoreAppSession} from './session-store.js'
2+
3+
export interface StoredStoreAuthSummary {
4+
store: string
5+
userId: string
6+
scopes: string[]
7+
acquiredAt: string
8+
expiresAt?: string
9+
refreshTokenExpiresAt?: string
10+
associatedUser?: StoredStoreAppSession['associatedUser']
11+
}
12+
13+
type StoreSessionStorage = Parameters<typeof listCurrentStoredStoreAppSessions>[0]
14+
15+
export function listStoredStoreAuthSummaries(storage?: StoreSessionStorage): StoredStoreAuthSummary[] {
16+
return listCurrentStoredStoreAppSessions(storage)
17+
.map((session) => ({
18+
store: session.store,
19+
userId: session.userId,
20+
scopes: session.scopes,
21+
acquiredAt: session.acquiredAt,
22+
...(session.expiresAt ? {expiresAt: session.expiresAt} : {}),
23+
...(session.refreshTokenExpiresAt ? {refreshTokenExpiresAt: session.refreshTokenExpiresAt} : {}),
24+
...(session.associatedUser ? {associatedUser: session.associatedUser} : {}),
25+
}))
26+
.sort((left, right) => left.store.localeCompare(right.store))
27+
}

0 commit comments

Comments
 (0)