From 7c0324019ccfff85afc8aea98d49cccdf129a2d9 Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Thu, 25 Jun 2026 19:30:17 +0300 Subject: [PATCH] Fetch preview store claim URL from GET request in store info Following shop/world#863938, GET /services/preview-stores/:id now returns claim_url alongside access_url. store info no longer makes a separate POST .../claim request, reducing backend calls from two to one and removing the now-redundant claim code path. Assisted-By: devx/77e7fd0b-38f4-44cb-aa92-23fda52bb067 --- .../store/create/preview/client.test.ts | 63 +++---------- .../services/store/create/preview/client.ts | 90 ++----------------- .../src/cli/services/store/info/index.test.ts | 46 +++++++--- .../src/cli/services/store/info/index.ts | 11 ++- 4 files changed, 64 insertions(+), 146 deletions(-) diff --git a/packages/store/src/cli/services/store/create/preview/client.test.ts b/packages/store/src/cli/services/store/create/preview/client.test.ts index 8eb413d6e8f..9199a95b95a 100644 --- a/packages/store/src/cli/services/store/create/preview/client.test.ts +++ b/packages/store/src/cli/services/store/create/preview/client.test.ts @@ -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' @@ -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}`, @@ -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, }), ) @@ -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', @@ -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, { diff --git a/packages/store/src/cli/services/store/create/preview/client.ts b/packages/store/src/cli/services/store/create/preview/client.ts index ec62c57af08..c3cf788eef2 100644 --- a/packages/store/src/cli/services/store/create/preview/client.ts +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -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 @@ -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 { @@ -115,7 +105,7 @@ export function previewStoreCreateHeaders(cliInstanceId: string): Record { +export function previewStoreAuthenticatedHeaders(cliInstanceId: string, adminApiToken: string): Record { return { ...previewStoreBaseHeaders(cliInstanceId), authorization: adminApiToken, @@ -168,40 +158,6 @@ export async function createPreviewStore( return narrowCreateResponse(parsed) } -export async function claimPreviewStore( - request: PreviewStoreClaimRequest, - options: PreviewStoreRequestOptions = {}, -): Promise { - 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 = {}, @@ -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() @@ -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 { @@ -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( @@ -366,6 +299,7 @@ function narrowGetResponse(parsed: RawPreviewStoreGetResponse): PreviewStoreGetR return { shop: {id, name, domain}, accessUrl, + ...(claimUrl ? {claimUrl} : {}), } } @@ -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]') @@ -395,5 +322,6 @@ function redactPreviewStoreGetResponse(parsed: RawPreviewStoreGetResponse): RawP return { ...parsed, ...(parsed.access_url ? {access_url: '[REDACTED]'} : {}), + ...(parsed.claim_url ? {claim_url: '[REDACTED]'} : {}), } } diff --git a/packages/store/src/cli/services/store/info/index.test.ts b/packages/store/src/cli/services/store/info/index.test.ts index 34142a98ebf..9b97cb30fd2 100644 --- a/packages/store/src/cli/services/store/info/index.test.ts +++ b/packages/store/src/cli/services/store/info/index.test.ts @@ -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' @@ -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', @@ -161,12 +160,10 @@ 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}) @@ -174,10 +171,6 @@ describe('getStoreInfo', () => { 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', @@ -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() @@ -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 () => { diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index c0f78e2bc8f..6789c339138 100644 --- a/packages/store/src/cli/services/store/info/index.ts +++ b/packages/store/src/cli/services/store/info/index.ts @@ -5,7 +5,7 @@ import {classifyAdminApiError, throwIfStoredStoreAuthIsInvalid} from '../admin-e import {recordStoreFqdnMetadata} from '../attribution.js' import {loadStoredStoreSession} from '../auth/session-lifecycle.js' import {getCurrentStoredStoreAppSession} from '../auth/session-store.js' -import {claimPreviewStore, getPreviewStore} from '../create/preview/client.js' +import {getPreviewStore} from '../create/preview/client.js' import {storeTypeHandle} from '../store-type.js' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' @@ -143,18 +143,17 @@ function isPreviewStoreSession(session: StoredStoreAppSession | undefined): sess interface PreviewStoreUrls { accessUrl: string - saveUrl: string + saveUrl?: string } async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promise { - const request = { + const previewStore = await getPreviewStore({ shopId: previewSession.preview.shopId, adminApiToken: previewSession.accessToken, - } - const [claim, previewStore] = await Promise.all([claimPreviewStore(request), getPreviewStore(request)]) + }) return { accessUrl: previewStore.accessUrl, - saveUrl: claim.claimUrl, + ...(previewStore.claimUrl ? {saveUrl: previewStore.claimUrl} : {}), } }