-
Notifications
You must be signed in to change notification settings - Fork 261
Add local store auth summary API #7709
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: donald/store-list-business-platform
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
|
@@ -71,6 +71,8 @@ function sanitizeStoredStoreAppSession(value: unknown): StoredStoreAppSession | | |
| return undefined | ||
| } | ||
|
|
||
| const associatedUser = sanitizeAssociatedUser(session.associatedUser) | ||
|
|
||
| return { | ||
| store: session.store, | ||
| clientId: session.clientId, | ||
|
|
@@ -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} : {}), | ||
| } | ||
| } | ||
|
|
||
| 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' || | ||
|
|
@@ -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> { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. 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(), | ||
|
|
||
| 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() | ||
| }) | ||
| }) | ||
| }) |
| 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)) | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice drive-by refactor 👍