Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import {
CLI_INSTANCE_HEADER,
CLI_VERSION_HEADER,
claimPreviewStore,
createPreviewStore,
getPreviewStore,
getOrCreateCliInstanceId,
previewStoreClaimHeaders,
previewStoreAuthenticatedHeaders,
previewStoreCreateHeaders,
} from './client.js'
import {shopifyFetch} from '@shopify/cli-kit/node/http'
Expand Down Expand Up @@ -62,8 +61,8 @@ describe('preview store client', () => {
})
})

test('builds claim request headers with the Admin API token', () => {
expect(previewStoreClaimHeaders('instance-1', 'shpat_token')).toEqual({
test('builds authenticated request headers with the Admin API token', () => {
expect(previewStoreAuthenticatedHeaders('instance-1', 'shpat_token')).toEqual({
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`,
Expand Down Expand Up @@ -139,52 +138,41 @@ describe('preview store client', () => {
await expect(createPreviewStore({}, {storage: inMemoryStorage('instance-1')})).rejects.toThrow(message)
})

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

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

expect(shopifyFetch).toHaveBeenCalledWith('https://app.shopify.com/services/preview-stores/123/claim', {
method: 'POST',
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',
}),
body: JSON.stringify({}),
})
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',
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',
}),
)

await claimPreviewStore(
{shopId: '123', adminApiToken: 'shpat_token', email: 'merchant@example.com'},
{storage: inMemoryStorage('instance-1')},
)

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 () => {
test('omits the claim URL when the backend degrades it to null', 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',
claim_url: null,
}),
)

Expand All @@ -193,14 +181,6 @@ describe('preview store client', () => {
{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',
Expand Down Expand Up @@ -261,21 +241,6 @@ describe('preview store client', () => {
expect(error.tryMessage).not.toContain('shpat_token')
})

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.stringMatching(/"claim_url":"\[REDACTED\]"/),
})
})

test('rejects malformed preview store lookup responses without leaking the access URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(200, {
Expand Down
90 changes: 9 additions & 81 deletions packages/store/src/cli/services/store/create/preview/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,6 @@ interface RawPreviewStoreCreateResponse {
access_url?: unknown
}

interface PreviewStoreClaimRequest {
shopId: string
adminApiToken: string
email?: string
}

interface PreviewStoreClaimResponse {
claimUrl: string
}

interface RawPreviewStoreClaimResponse {
claim_url?: unknown
}

interface PreviewStoreGetRequest {
shopId: string
adminApiToken: string
Expand All @@ -78,11 +64,15 @@ interface PreviewStoreGetRequest {
interface PreviewStoreGetResponse {
shop: PreviewStoreResponseShop
accessUrl: string
// The backend mints the claim URL inline; it degrades to null when minting fails, so callers
// treat an absent value as "retry later" rather than a hard error.
claimUrl?: string
}

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

interface RawPreviewStoreErrorResponse {
Expand Down Expand Up @@ -115,7 +105,7 @@ export function previewStoreCreateHeaders(cliInstanceId: string): Record<string,
return previewStoreBaseHeaders(cliInstanceId)
}

export function previewStoreClaimHeaders(cliInstanceId: string, adminApiToken: string): Record<string, string> {
export function previewStoreAuthenticatedHeaders(cliInstanceId: string, adminApiToken: string): Record<string, string> {
return {
...previewStoreBaseHeaders(cliInstanceId),
authorization: adminApiToken,
Expand Down Expand Up @@ -168,40 +158,6 @@ export async function createPreviewStore(
return narrowCreateResponse(parsed)
}

export async function claimPreviewStore(
request: PreviewStoreClaimRequest,
options: PreviewStoreRequestOptions = {},
): Promise<PreviewStoreClaimResponse> {
const fqdn = await appManagementFqdn()
const url = `https://${fqdn}/services/preview-stores/${encodeURIComponent(request.shopId)}/claim`
const body = JSON.stringify({...(request.email ? {email: request.email} : {})})

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

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

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

return narrowClaimResponse(parsed)
}

export async function getPreviewStore(
request: PreviewStoreGetRequest,
options: PreviewStoreRequestOptions = {},
Expand All @@ -211,7 +167,7 @@ export async function getPreviewStore(

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

const rawText = await response.text()
Expand Down Expand Up @@ -292,17 +248,6 @@ function parseErrorBody(rawText: string): RawPreviewStoreErrorResponse {
}
}

function previewStoreClaimError(status: number, rawText: string): {message: string; tryMessage?: string} {
const parsed = parseErrorBody(rawText)
const redactedRawText = redactPreviewStoreRawText(rawText)

return {
message: `Preview store claim URL request failed with HTTP ${status}.`,
tryMessage:
parsed.message ?? (redactedRawText.length > 0 ? redactedRawText.slice(0, 1000) : 'No response body returned.'),
}
}

function previewStoreGetError(status: number, rawText: string): {message: string; tryMessage?: string} {
const parsed = parseErrorBody(rawText)
return {
Expand Down Expand Up @@ -336,25 +281,13 @@ function narrowCreateResponse(parsed: RawPreviewStoreCreateResponse): PreviewSto
}
}

function narrowClaimResponse(parsed: RawPreviewStoreClaimResponse): PreviewStoreClaimResponse {
const claimUrl = typeof parsed.claim_url === 'string' ? parsed.claim_url : undefined

if (!claimUrl) {
throw new AbortError(
'Preview store claim URL response is missing required fields.',
`Got: ${JSON.stringify(redactPreviewStoreClaimResponse(parsed)).slice(0, 500)}`,
)
}

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
const claimUrl = typeof parsed.claim_url === 'string' ? parsed.claim_url : undefined

if (!id || !name || !domain || !accessUrl) {
throw new AbortError(
Expand All @@ -366,6 +299,7 @@ function narrowGetResponse(parsed: RawPreviewStoreGetResponse): PreviewStoreGetR
return {
shop: {id, name, domain},
accessUrl,
...(claimUrl ? {claimUrl} : {}),
}
}

Expand All @@ -377,13 +311,6 @@ function redactPreviewStoreResponse(parsed: RawPreviewStoreCreateResponse): RawP
}
}

function redactPreviewStoreClaimResponse(parsed: RawPreviewStoreClaimResponse): RawPreviewStoreClaimResponse {
return {
...parsed,
...(parsed.claim_url ? {claim_url: '[REDACTED]'} : {}),
}
}

function redactPreviewStoreRawText(rawText: string): string {
return rawText
.replace(/(["']?(?:admin_api_token|adminApiToken)["']?\s*:\s*["'])[^"']+/gi, '$1[REDACTED]')
Expand All @@ -395,5 +322,6 @@ function redactPreviewStoreGetResponse(parsed: RawPreviewStoreGetResponse): RawP
return {
...parsed,
...(parsed.access_url ? {access_url: '[REDACTED]'} : {}),
...(parsed.claim_url ? {claim_url: '[REDACTED]'} : {}),
}
}
46 changes: 36 additions & 10 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, getPreviewStore} from '../create/preview/client.js'
import {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 @@ -128,7 +128,6 @@ describe('getStoreInfo', () => {
expect(fetchOrganizationShop).toHaveBeenCalledWith({store: SHOP, organizationId: '149572536', noPrompt: false})
expect(loadStoredStoreSession).not.toHaveBeenCalled()
expect(graphqlRequest).not.toHaveBeenCalled()
expect(claimPreviewStore).not.toHaveBeenCalled()
expect(getPreviewStore).not.toHaveBeenCalled()
expect(result).toEqual({
id: 'gid://shopify/Shop/72193245184',
Expand Down Expand Up @@ -161,23 +160,17 @@ describe('getStoreInfo', () => {
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',
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})

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

expect(fetchDestinationsContext).not.toHaveBeenCalled()
expect(fetchOrganizationShop).not.toHaveBeenCalled()
expect(recordStoreFqdnMetadata).toHaveBeenCalledWith(SHOP, true, '123')
expect(claimPreviewStore).toHaveBeenCalledWith({
shopId: '123',
adminApiToken: 'shpat_preview_token',
})
expect(getPreviewStore).toHaveBeenCalledWith({
shopId: '123',
adminApiToken: 'shpat_preview_token',
Expand All @@ -193,6 +186,39 @@ describe('getStoreInfo', () => {
expect(result.adminUrl).toBeUndefined()
})

test('omits the save URL when the preview store lookup degrades the claim URL', async () => {
vi.mocked(getCurrentStoredStoreAppSession).mockReturnValueOnce({
store: SHOP,
clientId: STORE_AUTH_APP_CLIENT_ID,
userId: 'preview:placeholder-uuid',
accessToken: 'shpat_preview_token',
scopes: [],
acquiredAt: '2026-06-08T12:00:00.000Z',
kind: 'preview',
preview: {
placeholderAccountUuid: 'placeholder-uuid',
shopId: '123',
name: 'Lavender Candles',
createdAt: '2026-06-08T12:00:00.000Z',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=stale-access-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})

expect(result).toEqual({
id: 'gid://shopify/Shop/123',
displayName: 'Lavender Candles',
subdomain: SHOP,
accessUrl: 'https://app.shopify.com/auth/preview-store?token=fresh-access-token',
})
expect(result.saveUrl).toBeUndefined()
})

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

Expand All @@ -214,7 +240,7 @@ describe('getStoreInfo', () => {
featurePreview: 'extended_variants',
adminUrl: 'https://admin.shopify.com/store/shop',
})
expect(claimPreviewStore).not.toHaveBeenCalled()
expect(getPreviewStore).not.toHaveBeenCalled()
})

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