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: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2196,7 +2196,7 @@ DESCRIPTION
Surface metadata about a Shopify store.

Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization,
store owner, type, plan, feature preview, admin URL, and save URL for preview stores.
store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.

Some details may be omitted when they are not available for the store.

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -6012,8 +6012,8 @@
"args": {
},
"customPluginName": "@shopify/store",
"description": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and save URL for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.",
"descriptionWithMarkdown": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and save URL for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.",
"description": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.",
"descriptionWithMarkdown": "Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.\n\nSome details may be omitted when they are not available for the store.\n\nUse `--json` for machine-readable output.",
"examples": [
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com",
"<%= config.bin %> <%= command.id %> --store shop.myshopify.com --json"
Expand Down
2 changes: 1 addition & 1 deletion packages/store/src/cli/commands/store/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli'
export default class StoreInfo extends StoreCommand {
static summary = 'Surface metadata about a Shopify store.'

static descriptionWithMarkdown = `Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and save URL for preview stores.
static descriptionWithMarkdown = `Returns available metadata about a store you have access to, such as its id, display name, subdomain, organization, store owner, type, plan, feature preview, admin URL, and access and save URLs for preview stores.

Some details may be omitted when they are not available for the store.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CLI_VERSION_HEADER,
claimPreviewStore,
createPreviewStore,
getPreviewStore,
getOrCreateCliInstanceId,
previewStoreClaimHeaders,
previewStoreCreateHeaders,
Expand Down Expand Up @@ -140,7 +141,9 @@ describe('preview store client', () => {

test('POSTs to /services/preview-stores/:shop_id/claim with the Admin API token', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {claim_url: 'https://admin.shopify.com/store-transfer/accept/claim-token'}),
response(201, {
claim_url: 'https://admin.shopify.com/store-transfer/accept/claim-token',
}),
)

const got = await claimPreviewStore(
Expand All @@ -157,12 +160,16 @@ describe('preview store client', () => {
}),
body: JSON.stringify({}),
})
expect(got).toEqual({claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token'})
expect(got).toEqual({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})
})

test('sends optional email when requesting a preview store claim URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {claim_url: 'https://admin.shopify.com/store-transfer/accept/claim-token'}),
response(201, {
claim_url: 'https://admin.shopify.com/store-transfer/accept/claim-token',
}),
)

await claimPreviewStore(
Expand All @@ -173,6 +180,33 @@ describe('preview store client', () => {
expect(vi.mocked(shopifyFetch).mock.calls[0]![1]!.body).toBe(JSON.stringify({email: 'merchant@example.com'}))
})

test('GETs /services/preview-stores/:shop_id with the Admin API token', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(200, {
shop: {id: 123, name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'},
access_url: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
}),
)

const got = await getPreviewStore(
{shopId: '123', adminApiToken: 'shpat_token'},
{storage: inMemoryStorage('instance-1')},
)

expect(shopifyFetch).toHaveBeenCalledWith('https://app.shopify.com/services/preview-stores/123', {
method: 'GET',
headers: expect.objectContaining({
[CLI_INSTANCE_HEADER]: 'instance-1',
authorization: 'shpat_token',
'X-Shopify-Access-Token': 'shpat_token',
}),
})
expect(got).toEqual({
shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'},
accessUrl: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
})
})

test('rejects malformed create responses without leaking the admin API token or access URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {
Expand Down Expand Up @@ -227,14 +261,34 @@ describe('preview store client', () => {
expect(error.tryMessage).not.toContain('shpat_token')
})

test('rejects malformed claim responses without leaking the claim URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(response(201, {claim_url: 123}))
test('rejects malformed claim responses without leaking returned URLs', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {
claim_url: 123,
}),
)

await expect(
claimPreviewStore({shopId: '123', adminApiToken: 'shpat_token'}, {storage: inMemoryStorage('instance-1')}),
).rejects.toMatchObject({
message: 'Preview store claim URL response is missing required fields.',
tryMessage: expect.stringContaining('"claim_url":"[REDACTED]"'),
tryMessage: expect.stringMatching(/"claim_url":"\[REDACTED\]"/),
})
})

test('rejects malformed preview store lookup responses without leaking the access URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(200, {
shop: {id: 123},
access_url: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
}),
)

await expect(
getPreviewStore({shopId: '123', adminApiToken: 'shpat_token'}, {storage: inMemoryStorage('instance-1')}),
).rejects.toMatchObject({
message: 'Preview store lookup response is missing required fields.',
tryMessage: expect.stringMatching(/"access_url":"\[REDACTED\]"/),
})
})
})
82 changes: 82 additions & 0 deletions packages/store/src/cli/services/store/create/preview/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@ interface RawPreviewStoreClaimResponse {
claim_url?: unknown
}

interface PreviewStoreGetRequest {
shopId: string
adminApiToken: string
}

interface PreviewStoreGetResponse {
shop: PreviewStoreResponseShop
accessUrl: string
}

interface RawPreviewStoreGetResponse {
shop?: RawPreviewStoreResponseShop
access_url?: unknown
}

interface RawPreviewStoreErrorResponse {
error_code?: string
message?: string
Expand Down Expand Up @@ -187,6 +202,38 @@ export async function claimPreviewStore(
return narrowClaimResponse(parsed)
}

export async function getPreviewStore(
request: PreviewStoreGetRequest,
options: PreviewStoreRequestOptions = {},
): Promise<PreviewStoreGetResponse> {
const fqdn = await appManagementFqdn()
const url = `https://${fqdn}/services/preview-stores/${request.shopId}`

const response = await shopifyFetch(url, {
method: 'GET',
headers: previewStoreClaimHeaders(getOrCreateCliInstanceId(options.storage), request.adminApiToken),
})

const rawText = await response.text()
if (!response.ok) {
const error = previewStoreGetError(response.status, rawText)
throw new AbortError(error.message, error.tryMessage)
}

let parsed: RawPreviewStoreGetResponse
try {
parsed = JSON.parse(rawText) as RawPreviewStoreGetResponse
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error'
throw new AbortError(
'Preview store lookup returned a non-JSON response.',
`Parse error: ${message}. Body (truncated): ${rawText.slice(0, 500)}`,
)
}

return narrowGetResponse(parsed)
}

function previewStoreError(status: number, rawText: string): {message: string; tryMessage?: string} {
const parsed = parseErrorBody(rawText)
const errorCode = parsed.error_code
Expand Down Expand Up @@ -256,6 +303,14 @@ function previewStoreClaimError(status: number, rawText: string): {message: stri
}
}

function previewStoreGetError(status: number, rawText: string): {message: string; tryMessage?: string} {
const parsed = parseErrorBody(rawText)
return {
message: `Preview store lookup failed with HTTP ${status}.`,
tryMessage: parsed.message ?? (rawText.length > 0 ? rawText.slice(0, 1000) : 'No response body returned.'),
}
}

function narrowCreateResponse(parsed: RawPreviewStoreCreateResponse): PreviewStoreCreateResponse {
const shop = parsed.shop
const id = typeof shop?.id === 'string' || typeof shop?.id === 'number' ? String(shop.id) : undefined
Expand Down Expand Up @@ -294,6 +349,26 @@ function narrowClaimResponse(parsed: RawPreviewStoreClaimResponse): PreviewStore
return {claimUrl}
}

function narrowGetResponse(parsed: RawPreviewStoreGetResponse): PreviewStoreGetResponse {
const shop = parsed.shop
const id = typeof shop?.id === 'string' || typeof shop?.id === 'number' ? String(shop.id) : undefined
const name = typeof shop?.name === 'string' ? shop.name : undefined
const domain = typeof shop?.domain === 'string' ? normalizeStoreFqdn(shop.domain) : undefined
const accessUrl = typeof parsed.access_url === 'string' ? parsed.access_url : undefined

if (!id || !name || !domain || !accessUrl) {
throw new AbortError(
'Preview store lookup response is missing required fields.',
`Got: ${JSON.stringify(redactPreviewStoreGetResponse(parsed)).slice(0, 500)}`,
)
}

return {
shop: {id, name, domain},
accessUrl,
}
}

function redactPreviewStoreResponse(parsed: RawPreviewStoreCreateResponse): RawPreviewStoreCreateResponse {
return {
...parsed,
Expand All @@ -315,3 +390,10 @@ function redactPreviewStoreRawText(rawText: string): string {
.replace(/(["']?(?:access_url|accessUrl|claim_url|claimUrl)["']?\s*:\s*["'])[^"']+/gi, '$1[REDACTED]')
.replace(/([?&](?:token|access_token)=)[^&\s"'<>]+/gi, '$1[REDACTED]')
}

function redactPreviewStoreGetResponse(parsed: RawPreviewStoreGetResponse): RawPreviewStoreGetResponse {
return {
...parsed,
...(parsed.access_url ? {access_url: '[REDACTED]'} : {}),
}
}
64 changes: 37 additions & 27 deletions packages/store/src/cli/services/store/info/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {STORE_AUTH_APP_CLIENT_ID} from '../auth/config.js'
import {loadStoredStoreSession} from '../auth/session-lifecycle.js'
import {clearStoredStoreAppSession, getCurrentStoredStoreAppSession} from '../auth/session-store.js'
import {recordStoreFqdnMetadata} from '../attribution.js'
import {claimPreviewStore} from '../create/preview/client.js'
import {claimPreviewStore, getPreviewStore} from '../create/preview/client.js'
import {AbortError, BugError} from '@shopify/cli-kit/node/error'
import {adminUrl} from '@shopify/cli-kit/node/api/admin'
import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql'
Expand Down Expand Up @@ -129,6 +129,7 @@ describe('getStoreInfo', () => {
expect(loadStoredStoreSession).not.toHaveBeenCalled()
expect(graphqlRequest).not.toHaveBeenCalled()
expect(claimPreviewStore).not.toHaveBeenCalled()
expect(getPreviewStore).not.toHaveBeenCalled()
expect(result).toEqual({
id: 'gid://shopify/Shop/72193245184',
displayName: 'My Shop (Org)',
Expand All @@ -143,31 +144,7 @@ describe('getStoreInfo', () => {
})
})

test('prefers BP when store auth exists and BP can resolve the store', async () => {
mockStoredStoreAuth()

const result = await getStoreInfo({store: SHOP})

expect(fetchDestinationsContext).toHaveBeenCalledWith({store: SHOP, noPrompt: true})
expect(fetchOrganizationShop).toHaveBeenCalledWith({store: SHOP, organizationId: '149572536', noPrompt: true})
expect(loadStoredStoreSession).not.toHaveBeenCalled()
expect(graphqlRequest).not.toHaveBeenCalled()
expect(result).toEqual({
id: 'gid://shopify/Shop/72193245184',
displayName: 'My Shop (Org)',
subdomain: SHOP,
organizationId: '149572536',
organizationName: 'Acme Holdings',
storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'},
type: 'production',
plan: 'grow',
featurePreview: 'extended_variants',
adminUrl: 'https://admin.shopify.com/store/shop',
})
expect(claimPreviewStore).not.toHaveBeenCalled()
})

test('returns a save URL for locally stored preview stores', async () => {
test('returns fresh access and save URLs for locally stored preview stores', async () => {
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({
store: SHOP,
clientId: STORE_AUTH_APP_CLIENT_ID,
Expand All @@ -181,12 +158,16 @@ describe('getStoreInfo', () => {
shopId: '123',
name: 'Lavender Candles',
createdAt: '2026-06-08T12:00:00.000Z',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=stale-access-token',
},
})
vi.mocked(claimPreviewStore).mockResolvedValueOnce({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})
vi.mocked(getPreviewStore).mockResolvedValueOnce({
shop: {id: '123', name: 'Lavender Candles', domain: SHOP},
accessUrl: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
})

const result = await getStoreInfo({store: SHOP})

Expand All @@ -196,15 +177,44 @@ describe('getStoreInfo', () => {
shopId: '123',
adminApiToken: 'shpat_preview_token',
})
expect(getPreviewStore).toHaveBeenCalledWith({
shopId: '123',
adminApiToken: 'shpat_preview_token',
})
expect(result).toEqual({
id: 'gid://shopify/Shop/123',
displayName: 'Lavender Candles',
subdomain: SHOP,
adminUrl: 'https://admin.shopify.com/store/shop',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})
})

test('prefers BP when store auth exists and BP can resolve the store', async () => {
mockStoredStoreAuth()

const result = await getStoreInfo({store: SHOP})

expect(fetchDestinationsContext).toHaveBeenCalledWith({store: SHOP, noPrompt: true})
expect(fetchOrganizationShop).toHaveBeenCalledWith({store: SHOP, organizationId: '149572536', noPrompt: true})
expect(loadStoredStoreSession).not.toHaveBeenCalled()
expect(graphqlRequest).not.toHaveBeenCalled()
expect(result).toEqual({
id: 'gid://shopify/Shop/72193245184',
displayName: 'My Shop (Org)',
subdomain: SHOP,
organizationId: '149572536',
organizationName: 'Acme Holdings',
storeOwner: {name: 'Jane Doe', email: 'jane@acme.com'},
type: 'production',
plan: 'grow',
featurePreview: 'extended_variants',
adminUrl: 'https://admin.shopify.com/store/shop',
})
expect(claimPreviewStore).not.toHaveBeenCalled()
})

test('falls back to stored store auth when BP cannot resolve a store-auth store', async () => {
mockStoreAuthFallback()

Expand Down
Loading
Loading