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
5 changes: 5 additions & 0 deletions .changeset/store-list-organization-id-lookup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/store': patch
---

Fix `store list --organization-id` reporting a valid organization as not found for accounts that belong to many organizations
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,12 @@ import {AppVersionsQuerySchema} from '../../api/graphql/get_versions_list.js'
import {BrandingSpecIdentifier} from '../../models/extensions/specifications/app_config_branding.js'
import {AppHomeSpecIdentifier} from '../../models/extensions/specifications/app_config_app_home.js'
import {AppAccessSpecIdentifier} from '../../models/extensions/specifications/app_config_app_access.js'
import {MinimalAppIdentifiers} from '../../models/organization.js'
import {MinimalAppIdentifiers, OrganizationSource} from '../../models/organization.js'
import {CreateAssetUrl} from '../../api/graphql/app-management/generated/create-asset-url.js'
import {RequestSourceScanUploadUrl} from '../../api/graphql/app-management/generated/request-source-scan-upload-url.js'
import {CreateSourceScan} from '../../api/graphql/app-management/generated/create-source-scan.js'
import {SourceExtension} from '../../api/graphql/app-management/generated/types.js'
import {fetchOrganizations} from '@shopify/organizations'
import {fetchOrganizationById, fetchOrganizations} from '@shopify/organizations'
import {describe, expect, test, vi, beforeEach} from 'vitest'
import {CLI_KIT_VERSION} from '@shopify/cli-kit/common/version'
import {fetch} from '@shopify/cli-kit/node/http'
Expand Down Expand Up @@ -2389,6 +2389,39 @@ describe('uidStrategyFromTypename', () => {
})
})

describe('orgFromId', () => {
test('resolves the organization through the shared lookup and stamps the source', async () => {
vi.mocked(fetchOrganizationById).mockResolvedValueOnce({id: '123', businessName: 'Org 123'})

const client = AppManagementClient.getInstance()
client.businessPlatformToken = () => Promise.resolve('business-platform-token')
const unsafeRefreshToken = vi.spyOn(client, 'unsafeRefreshToken').mockResolvedValue('refreshed-token')
client.session = vi.fn().mockResolvedValue({
token: 'refreshed-token',
businessPlatformToken: 'refreshed-business-platform-token',
}) as unknown as typeof client.session

const result = await client.orgFromId('123')

expect(result).toEqual({id: '123', businessName: 'Org 123', source: OrganizationSource.BusinessPlatform})
// The client's own handler refreshes its cached session, not just the stored token.
const [organizationId, token, unauthorizedHandler] = vi.mocked(fetchOrganizationById).mock.calls[0]!
expect(organizationId).toBe('123')
expect(token).toBe('business-platform-token')
await expect(unauthorizedHandler!.handler()).resolves.toEqual({token: 'refreshed-business-platform-token'})
expect(unsafeRefreshToken).toHaveBeenCalledOnce()
})

test('returns undefined when the lookup finds no organization', async () => {
vi.mocked(fetchOrganizationById).mockResolvedValueOnce(undefined)

const client = AppManagementClient.getInstance()
client.businessPlatformToken = () => Promise.resolve('business-platform-token')

await expect(client.orgFromId('9999999')).resolves.toBeUndefined()
})
})

describe('devStoreCapReached', () => {
test('queries Business Platform and returns the organization development store cap state', async () => {
const mockedResponse: DevStoreCapReachedQuery = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,6 @@ import {AppInstallCount} from '../../api/graphql/app-management/generated/app-in
import {CreateApp, CreateAppMutationVariables} from '../../api/graphql/app-management/generated/create-app.js'
import {FetchSpecifications} from '../../api/graphql/app-management/generated/specifications.js'
import {ListApps} from '../../api/graphql/app-management/generated/apps.js'
import {FindOrganizations} from '../../api/graphql/business-platform-destinations/generated/find-organizations.js'
import {UserInfo} from '../../api/graphql/business-platform-destinations/generated/user-info.js'
import {AvailableTopics} from '../../api/graphql/webhooks/generated/available-topics.js'
import {CliTesting} from '../../api/graphql/webhooks/generated/cli-testing.js'
Expand All @@ -134,7 +133,7 @@ import {
} from '../../api/graphql/app-management/generated/app-logs-subscribe.js'
import {SourceExtension} from '../../api/graphql/app-management/generated/types.js'
import {WebhookSubscriptionSpecIdentifier} from '../../models/extensions/specifications/app_config_webhook_subscription.js'
import {fetchOrganizations} from '@shopify/organizations'
import {fetchOrganizationById, fetchOrganizations} from '@shopify/organizations'
import {getAppAutomationToken} from '@shopify/cli-kit/node/environment'
import {ensureAuthenticatedAppManagementAndBusinessPlatform, Session} from '@shopify/cli-kit/node/session'
import {isUnitTest} from '@shopify/cli-kit/node/context/local'
Expand Down Expand Up @@ -377,22 +376,15 @@ export class AppManagementClient implements DeveloperPlatformClient {
}

async orgFromId(orgId: string): Promise<Organization | undefined> {
const base64Id = encodedGidFromOrganizationIdForBP(orgId)
const variables = {organizationId: base64Id}
const organizationResult = await this.businessPlatformRequest({
query: FindOrganizations,
variables,
cacheOptions: {cacheTTL: {hours: 6}},
})
const org = organizationResult.currentUserAccount?.organization
const org = await fetchOrganizationById(
orgId,
await this.businessPlatformToken(),
this.createUnauthorizedHandler('businessPlatform'),
)
if (!org) {
return
}
return {
id: orgId,
businessName: org.name,
source: this.organizationSource,
}
return {...org, source: this.organizationSource}
}

async orgAndApps(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import * as Types from './types.js'

import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core'

export type FindOrganizationsQueryVariables = Types.Exact<{
export type FindOrganizationQueryVariables = Types.Exact<{
organizationId: Types.Scalars['OrganizationID']['input']
}>

export type FindOrganizationsQuery = {currentUserAccount?: {organization?: {id: string; name: string} | null} | null}
export type FindOrganizationQuery = {currentUserAccount?: {organization?: {id: string; name: string} | null} | null}

export const FindOrganizations = {
export const FindOrganization = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: {kind: 'Name', value: 'FindOrganizations'},
name: {kind: 'Name', value: 'FindOrganization'},
variableDefinitions: [
{
kind: 'VariableDefinition',
Expand Down Expand Up @@ -59,4 +59,4 @@ export const FindOrganizations = {
},
},
],
} as unknown as DocumentNode<FindOrganizationsQuery, FindOrganizationsQueryVariables>
} as unknown as DocumentNode<FindOrganizationQuery, FindOrganizationQueryVariables>
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
query FindOrganizations($organizationId: OrganizationID!) {
query FindOrganization($organizationId: OrganizationID!) {
currentUserAccount {
organization(id: $organizationId) {
id
Expand Down
94 changes: 93 additions & 1 deletion packages/organizations/src/cli/services/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './fetch.js'
import {fetchOrganizationById, fetchOrganizations, fetchOrganizationsWithAccessInfo} from './fetch.js'
import {describe, expect, test, vi} from 'vitest'
import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
import {nonRandomUUID} from '@shopify/cli-kit/node/crypto'

vi.mock('@shopify/cli-kit/node/api/business-platform')
vi.mock('@shopify/cli-kit/node/session')
Expand Down Expand Up @@ -210,3 +211,94 @@ describe('fetchOrganizationsWithAccessInfo', () => {
})
})
})

describe('fetchOrganizationById', () => {
test('returns the organization for a numeric ID, addressed by its encoded GID', async () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}},
})

const organization = await fetchOrganizationById('1234', 'test-token')

expect(organization).toEqual({id: '1234', businessName: 'My Org'})
expect(businessPlatformRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({
token: 'test-token',
variables: {organizationId: ENCODED_GID_1},
// Keyed by the token, hashed: the cache is shared across every account on the machine.
cacheOptions: {cacheTTL: {hours: 6}, cacheExtraKey: nonRandomUUID('test-token')},
}),
)
})

test('keys the cache separately for each token, and never stores the token itself', async () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}},
})

await fetchOrganizationById('1234', 'first-account-token')
await fetchOrganizationById('1234', 'second-account-token')

const [first, second] = vi
.mocked(businessPlatformRequestDoc)
.mock.calls.map(([options]) => (options as {cacheOptions: {cacheExtraKey: string}}).cacheOptions.cacheExtraKey)
expect(first).not.toEqual(second)
expect([first, second]).not.toContain('first-account-token')
})

test('accepts an organization GID and requests the same encoded ID', async () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}},
})

const organization = await fetchOrganizationById('gid://organization/Organization/1234', 'test-token')

expect(organization).toEqual({id: 'gid://organization/Organization/1234', businessName: 'My Org'})
expect(businessPlatformRequestDoc).toHaveBeenCalledWith(
expect.objectContaining({variables: {organizationId: ENCODED_GID_1}}),
)
})

test('returns undefined when the account cannot reach an organization with that ID', async () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({currentUserAccount: {organization: null}})

await expect(fetchOrganizationById('9999999', 'test-token')).resolves.toBeUndefined()
})

test('returns undefined when BP cannot resolve currentUserAccount', async () => {
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({currentUserAccount: null})

await expect(fetchOrganizationById('1234', 'test-token')).resolves.toBeUndefined()
})

test('throws without requesting anything when the ID is not numeric', async () => {
await expect(fetchOrganizationById('not-an-id', 'test-token')).rejects.toThrow('Invalid organization ID: not-an-id')
expect(businessPlatformRequestDoc).not.toHaveBeenCalled()
})

test('fetches a token when none is provided, and refreshes it on an unauthorized response', async () => {
vi.mocked(ensureAuthenticatedBusinessPlatform)
.mockResolvedValueOnce('initial-token')
.mockResolvedValueOnce('refreshed-token')
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}},
})

await fetchOrganizationById('1234')

const requestOptions = vi.mocked(businessPlatformRequestDoc).mock.calls[0]?.[0] as any
expect(requestOptions.token).toBe('initial-token')
await expect(requestOptions.unauthorizedHandler.handler()).resolves.toEqual({token: 'refreshed-token'})
})

test('uses a caller-provided unauthorized handler instead of the default', async () => {
const unauthorizedHandler = {type: 'token_refresh' as const, handler: async () => ({token: 'client-token'})}
vi.mocked(businessPlatformRequestDoc).mockResolvedValue({
currentUserAccount: {organization: {id: ENCODED_GID_1, name: 'My Org'}},
})

await fetchOrganizationById('1234', 'test-token', unauthorizedHandler)

expect(businessPlatformRequestDoc).toHaveBeenCalledWith(expect.objectContaining({unauthorizedHandler}))
})
})
61 changes: 52 additions & 9 deletions packages/organizations/src/cli/services/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import {businessPlatformTokenRefreshHandler} from './business-platform.js'
import {FindOrganization} from '../api/graphql/business-platform-destinations/generated/find_organization.js'
import {ListOrganizations} from '../api/graphql/business-platform-destinations/generated/organizations.js'
import {Organization, OrganizationWithDetails} from '../models/organization.js'
import {businessPlatformRequestDoc} from '@shopify/cli-kit/node/api/business-platform'
import {type UnauthorizedHandler} from '@shopify/cli-kit/node/api/graphql'
import {ensureAuthenticatedBusinessPlatform} from '@shopify/cli-kit/node/session'
import {nonRandomUUID} from '@shopify/cli-kit/node/crypto'
import {AbortError} from '@shopify/cli-kit/node/error'
import {numericIdFromEncodedGid} from '@shopify/cli-kit/common/gid'
import {encodeGid, numericIdFromEncodedGid, numericIdFromGid} from '@shopify/cli-kit/common/gid'

interface FetchOrganizationsWithAccessInfoResult {
organizations: Organization[]
Expand All @@ -26,20 +30,59 @@ export async function fetchOrganizationsWithAccessInfo(
return fetchOrganizationsWithDetails(token)
}

async function fetchOrganizationsWithDetails(token?: string): Promise<FetchOrganizationsWithDetailsResult> {
/**
* Looks up a single organization the current account belongs to, by ID.
*
* The lookup is unfiltered, since the schema offers no `APPS_CLI` filter for it, so it can resolve
* an organization the account can't reach from the CLI; the org-scoped request that follows stays
* the authority there. Its result is cached, so callers that report a miss to the user should
* confirm it against `fetchOrganizationsWithAccessInfo` first.
*
* @param organizationId - Numeric organization ID, or an organization GID.
* @param token - Business Platform token. Fetched when not provided.
* @param unauthorizedHandler - Overrides the default token refresh.
* @returns The organization, or undefined when the account has none with that ID.
*/
export async function fetchOrganizationById(
organizationId: string,
token?: string,
unauthorizedHandler?: UnauthorizedHandler,
): Promise<Organization | undefined> {
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform())
const unauthorizedHandler = {
type: 'token_refresh' as const,
handler: async () => {
const newToken = await ensureAuthenticatedBusinessPlatform()
return {token: newToken}
},

const result = await businessPlatformRequestDoc({
query: FindOrganization,
token: resolvedToken,
variables: {organizationId: encodedOrganizationGid(organizationId)},
unauthorizedHandler: unauthorizedHandler ?? businessPlatformTokenRefreshHandler(),
// The cache key covers only the query and variables, and the cache is shared by every account
// on the machine, so the token that authorized the lookup is part of it — hashed, because the
// key is persisted. A rotated token misses rather than reading another account's entry.
cacheOptions: {cacheTTL: {hours: 6}, cacheExtraKey: nonRandomUUID(resolvedToken)},
})

const organization = result.currentUserAccount?.organization
if (!organization) return undefined

return {id: organizationId, businessName: organization.name}
}

// Business Platform destinations endpoints address organizations by a base64-encoded GID.
function encodedOrganizationGid(organizationId: string): string {
const numericId = organizationId.startsWith('gid://') ? numericIdFromGid(organizationId) : organizationId
if (numericId === undefined || !/^\d+$/.test(numericId)) {
throw new AbortError(`Invalid organization ID: ${organizationId}`)
}
return encodeGid(`gid://organization/Organization/${numericId}`)
}

async function fetchOrganizationsWithDetails(token?: string): Promise<FetchOrganizationsWithDetailsResult> {
const resolvedToken = token ?? (await ensureAuthenticatedBusinessPlatform())

const result = await businessPlatformRequestDoc({
query: ListOrganizations,
token: resolvedToken,
unauthorizedHandler,
unauthorizedHandler: businessPlatformTokenRefreshHandler(),
})

if (!result.currentUserAccount) {
Expand Down
2 changes: 1 addition & 1 deletion packages/organizations/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export {fetchOrganizations, fetchOrganizationsWithAccessInfo} from './cli/services/fetch.js'
export {fetchOrganizations, fetchOrganizationsWithAccessInfo, fetchOrganizationById} from './cli/services/fetch.js'
export {selectOrg} from './cli/services/select.js'
export {selectOrganizationPrompt} from './cli/prompts/organization.js'
export type {Organization, OrganizationWithDetails, OrganizationStatus} from './cli/models/organization.js'
Expand Down
Loading
Loading