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/preview-store-claim-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/store': minor
---

Fetch and display the claim URL after creating a preview store.
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('store create preview command', () => {
subdomain: 'x.myshopify.com',
country: 'US',
storefrontUrl: 'https://x.myshopify.com',
saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
},
}
vi.mocked(createPreviewStoreCommand).mockResolvedValueOnce(result)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
CLI_INSTANCE_HEADER,
CLI_VERSION_HEADER,
claimPreviewStore,
createPreviewStore,
getOrCreateCliInstanceId,
previewStoreClaimHeaders,
previewStoreCreateHeaders,
} from './client.js'
import {shopifyFetch} from '@shopify/cli-kit/node/http'
Expand Down Expand Up @@ -59,6 +61,18 @@ describe('preview store client', () => {
})
})

test('builds claim request headers with the Admin API token', () => {
expect(previewStoreClaimHeaders('instance-1', 'shpat_token')).toEqual({
Accept: 'application/json',
'Content-Type': 'application/json',
'User-Agent': `Shopify CLI; v=${CLI_KIT_VERSION}`,
[CLI_INSTANCE_HEADER]: 'instance-1',
[CLI_VERSION_HEADER]: CLI_KIT_VERSION,
authorization: 'shpat_token',
'X-Shopify-Access-Token': 'shpat_token',
})
})

test('POSTs to /services/preview-stores with optional name and country variables and no authorization', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {
Expand Down Expand Up @@ -124,7 +138,42 @@ describe('preview store client', () => {
await expect(createPreviewStore({}, {storage: inMemoryStorage('instance-1')})).rejects.toThrow(message)
})

test('rejects malformed success responses without leaking the admin API token or access URL', async () => {
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'}),
)

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

expect(shopifyFetch).toHaveBeenCalledWith('https://app.shopify.com/services/preview-stores/123/claim', {
method: 'POST',
headers: expect.objectContaining({
[CLI_INSTANCE_HEADER]: 'instance-1',
authorization: 'shpat_token',
'X-Shopify-Access-Token': 'shpat_token',
}),
body: JSON.stringify({}),
})
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'}),
)

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('rejects malformed create responses without leaking the admin API token or access URL', async () => {
vi.mocked(shopifyFetch).mockResolvedValueOnce(
response(201, {
shop: {id: 123},
Expand Down Expand Up @@ -177,4 +226,15 @@ describe('preview store client', () => {
expect(error.tryMessage).not.toContain('access-token')
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}))

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]"'),
})
})
})
107 changes: 100 additions & 7 deletions packages/store/src/cli/services/store/create/preview/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ interface PreviewStoreClientStorageSchema {
cliInstanceId?: string
}

interface PreviewStoreRequestOptions {
storage?: LocalStorage<PreviewStoreClientStorageSchema>
}

let _clientStorage: LocalStorage<PreviewStoreClientStorageSchema> | undefined

function clientStorage() {
_clientStorage ??= new LocalStorage<PreviewStoreClientStorageSchema>({projectName: 'shopify-cli-store'})
return _clientStorage
}

export interface PreviewStoreClientOptions {
storage?: LocalStorage<PreviewStoreClientStorageSchema>
}
export interface PreviewStoreClientOptions extends PreviewStoreRequestOptions {}

interface PreviewStoreCreateRequest {
name?: string
Expand Down Expand Up @@ -54,6 +56,20 @@ interface RawPreviewStoreCreateResponse {
access_url?: unknown
}

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

interface PreviewStoreClaimResponse {
claimUrl: string
}

interface RawPreviewStoreClaimResponse {
claim_url?: unknown
}

interface RawPreviewStoreErrorResponse {
error_code?: string
message?: string
Expand All @@ -70,7 +86,7 @@ export function getOrCreateCliInstanceId(
return next
}

export function previewStoreCreateHeaders(cliInstanceId: string): Record<string, string> {
function previewStoreBaseHeaders(cliInstanceId: string): Record<string, string> {
return {
Accept: 'application/json',
'Content-Type': 'application/json',
Expand All @@ -80,6 +96,18 @@ export function previewStoreCreateHeaders(cliInstanceId: string): Record<string,
}
}

export function previewStoreCreateHeaders(cliInstanceId: string): Record<string, string> {
return previewStoreBaseHeaders(cliInstanceId)
}

export function previewStoreClaimHeaders(cliInstanceId: string, adminApiToken: string): Record<string, string> {
return {
...previewStoreBaseHeaders(cliInstanceId),
authorization: adminApiToken,
'X-Shopify-Access-Token': adminApiToken,
}
}

export async function createPreviewStore(
request: PreviewStoreCreateRequest,
options: PreviewStoreClientOptions = {},
Expand Down Expand Up @@ -122,7 +150,41 @@ export async function createPreviewStore(
)
}

return narrowResponse(parsed)
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`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels dirty to generate this URL here, and to have to specify it in tests as a raw string. But I can't think of a better way at the moment, because it's not really a constant.

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)
}

function previewStoreError(status: number, rawText: string): {message: string; tryMessage?: string} {
Expand Down Expand Up @@ -183,7 +245,18 @@ function parseErrorBody(rawText: string): RawPreviewStoreErrorResponse {
}
}

function narrowResponse(parsed: RawPreviewStoreCreateResponse): PreviewStoreCreateResponse {
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 narrowCreateResponse(parsed: RawPreviewStoreCreateResponse): PreviewStoreCreateResponse {
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
Expand All @@ -208,6 +281,19 @@ function narrowResponse(parsed: RawPreviewStoreCreateResponse): PreviewStoreCrea
}
}

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 redactPreviewStoreResponse(parsed: RawPreviewStoreCreateResponse): RawPreviewStoreCreateResponse {
return {
...parsed,
Expand All @@ -216,9 +302,16 @@ 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]')
.replace(/(["']?(?:access_url|accessUrl)["']?\s*:\s*["'])[^"']+/gi, '$1[REDACTED]')
.replace(/(["']?(?:access_url|accessUrl|claim_url|claimUrl)["']?\s*:\s*["'])[^"']+/gi, '$1[REDACTED]')
.replace(/([?&](?:token|access_token)=)[^&\s"'<>]+/gi, '$1[REDACTED]')
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ describe('preview store create service', () => {
const setStoredStoreAppSession = vi.fn()
const recordStoreFqdnMetadata = vi.fn()
const setLastSeenUserId = vi.fn()
const claimPreviewStore = vi.fn(async () => ({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
}))

const result = await createPreviewStoreCommand(
{name: 'Lavender Candles', country: 'US'},
Expand All @@ -17,6 +20,7 @@ describe('preview store create service', () => {
adminApiToken: 'shpat_token',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
})),
claimPreviewStore,
setStoredStoreAppSession,
recordStoreFqdnMetadata,
setLastSeenUserId,
Expand Down Expand Up @@ -54,8 +58,10 @@ describe('preview store create service', () => {
subdomain: 'x12y45z.myshopify.com',
country: 'US',
storefrontUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
},
})
expect(claimPreviewStore).toHaveBeenCalledWith({shopId: '123', adminApiToken: 'shpat_token'}, undefined)
})

test('uses the shop id as the preview user id when no placeholder account uuid is returned', async () => {
Expand All @@ -70,6 +76,9 @@ describe('preview store create service', () => {
adminApiToken: 'shpat_token',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
})),
claimPreviewStore: vi.fn(async () => ({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})),
setStoredStoreAppSession,
recordStoreFqdnMetadata: vi.fn(),
setLastSeenUserId,
Expand All @@ -83,6 +92,33 @@ describe('preview store create service', () => {
expect(setLastSeenUserId).toHaveBeenCalledWith(`${PREVIEW_USER_ID_PREFIX}123`)
})

test('passes client options to both create and claim requests', async () => {
const client = {} as any
const createPreviewStore = vi.fn(async () => ({
shop: {id: '123', name: 'Lavender Candles', domain: 'x12y45z.myshopify.com'},
adminApiToken: 'shpat_token',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
}))
const claimPreviewStore = vi.fn(async () => ({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
}))

await createPreviewStoreCommand(
{client},
{
createPreviewStore,
claimPreviewStore,
setStoredStoreAppSession: vi.fn(),
recordStoreFqdnMetadata: vi.fn(),
setLastSeenUserId: vi.fn(),
now: () => new Date('2026-06-08T12:00:00.000Z'),
},
)

expect(createPreviewStore).toHaveBeenCalledWith({name: undefined, country: undefined}, client)
expect(claimPreviewStore).toHaveBeenCalledWith({shopId: '123', adminApiToken: 'shpat_token'}, client)
})

test('persists a store session and returns success when recording store metadata fails', async () => {
const setStoredStoreAppSession = vi.fn()
const recordStoreFqdnMetadata = vi.fn(async () => {
Expand All @@ -98,6 +134,9 @@ describe('preview store create service', () => {
adminApiToken: 'shpat_token',
accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token',
})),
claimPreviewStore: vi.fn(async () => ({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})),
setStoredStoreAppSession,
recordStoreFqdnMetadata,
setLastSeenUserId,
Expand All @@ -124,6 +163,9 @@ describe('preview store create service', () => {
createPreviewStore: vi.fn(async () => {
throw new Error('Preview store creation failed.')
}),
claimPreviewStore: vi.fn(async () => ({
claimUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token',
})),
setStoredStoreAppSession,
recordStoreFqdnMetadata,
setLastSeenUserId,
Expand Down
Loading
Loading