From 8491e6d77b6be9d9d61321cdf9780e354805945f Mon Sep 17 00:00:00 2001 From: Ariel Caplan Date: Tue, 16 Jun 2026 15:45:06 +0300 Subject: [PATCH] Add preview store access URL to store info --- packages/cli/README.md | 2 +- packages/cli/oclif.manifest.json | 4 +- packages/store/src/cli/commands/store/info.ts | 2 +- .../store/create/preview/client.test.ts | 66 +++++++++++++-- .../services/store/create/preview/client.ts | 82 +++++++++++++++++++ .../src/cli/services/store/info/index.test.ts | 64 +++++++++------ .../src/cli/services/store/info/index.ts | 29 +++++-- .../cli/services/store/info/result.test.ts | 5 ++ .../src/cli/services/store/info/result.ts | 1 + .../src/cli/services/store/info/types.ts | 1 + 10 files changed, 210 insertions(+), 46 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 6601b04a33f..62a97253975 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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. diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index e0c67e83ca1..72d98e5d31c 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -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" diff --git a/packages/store/src/cli/commands/store/info.ts b/packages/store/src/cli/commands/store/info.ts index 0a4ed544f8f..e1aa5a93c1f 100644 --- a/packages/store/src/cli/commands/store/info.ts +++ b/packages/store/src/cli/commands/store/info.ts @@ -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. 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 22e233229e9..8eb413d6e8f 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 @@ -3,6 +3,7 @@ import { CLI_VERSION_HEADER, claimPreviewStore, createPreviewStore, + getPreviewStore, getOrCreateCliInstanceId, previewStoreClaimHeaders, previewStoreCreateHeaders, @@ -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( @@ -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( @@ -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, { @@ -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\]"/), }) }) }) 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 718fd2af474..ec62c57af08 100644 --- a/packages/store/src/cli/services/store/create/preview/client.ts +++ b/packages/store/src/cli/services/store/create/preview/client.ts @@ -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 @@ -187,6 +202,38 @@ export async function claimPreviewStore( return narrowClaimResponse(parsed) } +export async function getPreviewStore( + request: PreviewStoreGetRequest, + options: PreviewStoreRequestOptions = {}, +): Promise { + 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 @@ -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 @@ -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, @@ -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]'} : {}), + } +} 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 0fe0e076556..caf1f416ff7 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} 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' @@ -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)', @@ -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, @@ -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}) @@ -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() diff --git a/packages/store/src/cli/services/store/info/index.ts b/packages/store/src/cli/services/store/info/index.ts index 2c2a01d5332..7e0b95b2d9f 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} from '../create/preview/client.js' +import {claimPreviewStore, getPreviewStore} from '../create/preview/client.js' import {AbortError} from '@shopify/cli-kit/node/error' import {adminUrl} from '@shopify/cli-kit/node/api/admin' import {graphqlRequest} from '@shopify/cli-kit/node/api/graphql' @@ -63,10 +63,11 @@ export async function getStoreInfo(options: GetStoreInfoOptions): Promise { - const claim = await claimPreviewStore({ +interface PreviewStoreUrls { + accessUrl: string + saveUrl: string +} + +async function fetchPreviewStoreUrls(previewSession: PreviewStoreSession): Promise { + const request = { shopId: previewSession.preview.shopId, adminApiToken: previewSession.accessToken, - }) - return claim.claimUrl + } + const [claim, previewStore] = await Promise.all([claimPreviewStore(request), getPreviewStore(request)]) + return { + accessUrl: previewStore.accessUrl, + saveUrl: claim.claimUrl, + } } async function safeFetchOrganizationShop( @@ -229,14 +239,15 @@ function buildBusinessPlatformResult(args: BuildBusinessPlatformResultArgs): Sto function buildPreviewStoreResult(args: { store: string previewSession: PreviewStoreSession - saveUrl: string + previewStoreUrls: PreviewStoreUrls }): StoreInfoResult { - const {store, previewSession, saveUrl} = args + const {store, previewSession, previewStoreUrls} = args const fields: Partial = { id: buildShopGid(previewSession.preview.shopId), displayName: previewSession.preview.name, adminUrl: buildAdminUrl(extractMyshopifyHandle(store)), - saveUrl, + accessUrl: previewStoreUrls.accessUrl, + saveUrl: previewStoreUrls.saveUrl, } return {...compact(fields), subdomain: store} as StoreInfoResult diff --git a/packages/store/src/cli/services/store/info/result.test.ts b/packages/store/src/cli/services/store/info/result.test.ts index 01761261a3d..06c817eed0f 100644 --- a/packages/store/src/cli/services/store/info/result.test.ts +++ b/packages/store/src/cli/services/store/info/result.test.ts @@ -35,6 +35,7 @@ describe('renderStoreInfoResult', () => { plan: 'grow', featurePreview: 'extended_variants', adminUrl: 'https://admin.shopify.com/store/acme-widgets', + accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token', saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token', }), 'json', @@ -53,6 +54,7 @@ describe('renderStoreInfoResult', () => { plan: 'grow', featurePreview: 'extended_variants', adminUrl: 'https://admin.shopify.com/store/acme-widgets', + accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token', saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token', }) expect(renderInfo).not.toHaveBeenCalled() @@ -75,6 +77,7 @@ describe('renderStoreInfoResult', () => { plan: 'grow', featurePreview: 'extended_variants', adminUrl: 'https://admin.shopify.com/store/acme-widgets', + accessUrl: 'https://app.shopify.com/auth/preview-store?token=access-token', saveUrl: 'https://admin.shopify.com/store-transfer/accept/claim-token', }), 'text', @@ -89,6 +92,7 @@ describe('renderStoreInfoResult', () => { expect(items).toContain('Plan: Grow') expect(items).toContain('Feature Preview: extended_variants') expect(items).toContain('Admin URL: https://admin.shopify.com/store/acme-widgets') + expect(items).toContain('Access URL: https://app.shopify.com/auth/preview-store?token=access-token') expect(items).toContain('Save URL: https://admin.shopify.com/store-transfer/accept/claim-token') }) @@ -114,6 +118,7 @@ describe('renderStoreInfoResult', () => { expect(items.some((item) => item.startsWith('Store owner'))).toBe(false) expect(items.some((item) => item.startsWith('Type'))).toBe(false) expect(items.some((item) => item.startsWith('Plan'))).toBe(false) + expect(items.some((item) => item.startsWith('Access URL'))).toBe(false) expect(items.some((item) => item.startsWith('Save URL'))).toBe(false) }) }) diff --git a/packages/store/src/cli/services/store/info/result.ts b/packages/store/src/cli/services/store/info/result.ts index 21b5b7ea728..ebd9b94aefa 100644 --- a/packages/store/src/cli/services/store/info/result.ts +++ b/packages/store/src/cli/services/store/info/result.ts @@ -26,6 +26,7 @@ function storeDetailItems(result: StoreInfoResult): string[] { push(items, 'Plan', result.plan ? capitalizeWords(result.plan) : undefined) push(items, 'Feature Preview', result.featurePreview) push(items, 'Admin URL', result.adminUrl) + push(items, 'Access URL', result.accessUrl) push(items, 'Save URL', result.saveUrl) return items } diff --git a/packages/store/src/cli/services/store/info/types.ts b/packages/store/src/cli/services/store/info/types.ts index 99f30bf8128..4ce92149a46 100644 --- a/packages/store/src/cli/services/store/info/types.ts +++ b/packages/store/src/cli/services/store/info/types.ts @@ -26,6 +26,7 @@ export interface StoreInfoResult { plan?: string featurePreview?: string adminUrl?: string + accessUrl?: string saveUrl?: string }