diff --git a/packages/cli-kit/src/private/node/constants.ts b/packages/cli-kit/src/private/node/constants.ts index aac03a2f4c..7c1c3cd1a9 100644 --- a/packages/cli-kit/src/private/node/constants.ts +++ b/packages/cli-kit/src/private/node/constants.ts @@ -41,6 +41,8 @@ export const environmentVariables = { spinAppHost: 'SPIN_APP_HOST', organization: 'SHOPIFY_CLI_ORGANIZATION', identityToken: 'SHOPIFY_CLI_IDENTITY_TOKEN', + identityTokenUserId: 'SHOPIFY_CLI_IDENTITY_USER_ID', + identityTokenExpiresAt: 'SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT', refreshToken: 'SHOPIFY_CLI_REFRESH_TOKEN', otelURL: 'SHOPIFY_CLI_OTEL_EXPORTER_OTLP_ENDPOINT', themeKitAccessDomain: 'SHOPIFY_CLI_THEME_KIT_ACCESS_DOMAIN', diff --git a/packages/cli-kit/src/private/node/session.test.ts b/packages/cli-kit/src/private/node/session.test.ts index 8d3593fd1b..99cc758ffd 100644 --- a/packages/cli-kit/src/private/node/session.test.ts +++ b/packages/cli-kit/src/private/node/session.test.ts @@ -24,7 +24,7 @@ import * as fqdnModule from '../../public/node/context/fqdn.js' import {themeToken} from '../../public/node/context/local.js' import {partnersRequest} from '../../public/node/api/partners.js' import {businessPlatformRequest} from '../../public/node/api/business-platform.js' -import {getAppAutomationToken} from '../../public/node/environment.js' +import {getAppAutomationToken, getIdentityTokenInformation} from '../../public/node/environment.js' import {nonRandomUUID} from '../../public/node/crypto.js' import {terminalSupportsPrompting} from '../../public/node/system.js' @@ -177,6 +177,67 @@ describe('ensureAuthenticated when previous session is invalid', () => { expect(fetchSessions).toHaveBeenCalledOnce() }) + test('imports identity bootstrap from the provided env with explicit user id and expiry', async () => { + vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue({}) + + const bootstrapEnv = { + SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token', + SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token', + SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id', + SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z', + } + + vi.mocked(getIdentityTokenInformation).mockReturnValue({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + userId: 'placeholder-user-id', + expiresAt: new Date('2026-05-14T12:00:00.000Z'), + }) + + const got = await ensureAuthenticated({}, bootstrapEnv) + + expect(got).toEqual({userId: 'placeholder-user-id'}) + expect(getIdentityTokenInformation).toHaveBeenCalledWith(bootstrapEnv) + expect(requestDeviceAuthorization).not.toHaveBeenCalled() + expect(pollForDeviceAuthorization).not.toHaveBeenCalled() + + const storedSession = vi.mocked(storeSessions).mock.calls[0]![0] + expect(storedSession[fqdn]!['placeholder-user-id']!.identity.userId).toBe('placeholder-user-id') + expect(storedSession[fqdn]!['placeholder-user-id']!.identity.expiresAt).toEqual( + new Date('2026-05-14T12:00:00.000Z'), + ) + }) + + test('imports identity bootstrap from the provided env when prompting is disabled', async () => { + vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') + vi.mocked(fetchSessions).mockResolvedValue(undefined) + vi.mocked(exchangeAccessForApplicationTokens).mockResolvedValue({}) + vi.mocked(getCurrentSessionId).mockReturnValue(undefined) + + const bootstrapEnv = { + SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token', + SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token', + SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id', + SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z', + } + + vi.mocked(getIdentityTokenInformation).mockReturnValue({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + userId: 'placeholder-user-id', + expiresAt: new Date('2026-05-14T12:00:00.000Z'), + }) + + const got = await ensureAuthenticated({}, bootstrapEnv, {noPrompt: true}) + + expect(got).toEqual({userId: 'placeholder-user-id'}) + expect(secureRemove).not.toHaveBeenCalled() + expect(requestDeviceAuthorization).not.toHaveBeenCalled() + expect(pollForDeviceAuthorization).not.toHaveBeenCalled() + }) + test('throws an error and logs out if there is no session and prompting is disabled,', async () => { // Given vi.mocked(validateSession).mockResolvedValueOnce('needs_full_auth') diff --git a/packages/cli-kit/src/private/node/session.ts b/packages/cli-kit/src/private/node/session.ts index 3b750d1cdc..1015bcc306 100644 --- a/packages/cli-kit/src/private/node/session.ts +++ b/packages/cli-kit/src/private/node/session.ts @@ -199,6 +199,7 @@ export async function ensureAuthenticated( {forceRefresh = false, noPrompt = false, forceNewSession = false}: EnsureAuthenticatedAdditionalOptions = {}, ): Promise { const fqdn = await identityFqdn() + const canAuthenticateWithoutPrompt = canAuthenticateWithoutPromptFromEnvironment(_env) const previousStoreFqdn = applications.adminApi?.storeFqdn if (previousStoreFqdn) { @@ -230,17 +231,21 @@ ${outputToken.json(applications)} let newSession = {} if (validationResult === 'needs_full_auth') { - await throwOnNoPrompt(noPrompt) + if (!canAuthenticateWithoutPrompt) { + await throwOnNoPrompt(noPrompt) + } outputDebug(outputContent`Initiating the full authentication flow...`) - newSession = await executeCompleteFlow(applications, currentSession?.identity.alias) + newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias) } else if (validationResult === 'needs_refresh' || forceRefresh) { outputDebug(outputContent`The current session is valid but needs refresh. Refreshing...`) try { newSession = await refreshTokens(currentSession!, applications) } catch (error) { if (error instanceof InvalidGrantError) { - await throwOnNoPrompt(noPrompt) - newSession = await executeCompleteFlow(applications, currentSession?.identity.alias) + if (!canAuthenticateWithoutPrompt) { + await throwOnNoPrompt(noPrompt) + } + newSession = await executeCompleteFlow(applications, _env, currentSession?.identity.alias) } else if (error instanceof InvalidRequestError) { await sessionStore.remove() throw new AbortError('\nError validating auth session', "We've cleared the current session, please try again") @@ -265,7 +270,7 @@ ${outputToken.json(applications)} const tokens = await tokensFor(applications, completeSession) - const envToken = getAppAutomationToken() + const envToken = getAppAutomationToken(_env) if (envToken && applications.partnersApi) { tokens.partners = (await exchangeCustomPartnerToken(envToken)).accessToken } @@ -275,6 +280,10 @@ ${outputToken.json(applications)} return tokens } +function canAuthenticateWithoutPromptFromEnvironment(env?: NodeJS.ProcessEnv): boolean { + return Boolean(getIdentityTokenInformation(env) || getAppAutomationToken(env)) +} + async function throwOnNoPrompt(noPrompt: boolean) { if (!noPrompt) return await logout() @@ -292,7 +301,11 @@ The CLI is currently unable to prompt for reauthentication.`, * @param applications - An object containing the applications we need to be authenticated with. * @param existingAlias - Optional alias from a previous session to preserve if the email fetch fails. */ -async function executeCompleteFlow(applications: OAuthApplications, existingAlias?: string): Promise { +async function executeCompleteFlow( + applications: OAuthApplications, + env: NodeJS.ProcessEnv | undefined, + existingAlias?: string, +): Promise { const scopes = getFlattenScopes(applications) const exchangeScopes = getExchangeScopes(applications) const store = applications.adminApi?.storeFqdn @@ -302,7 +315,7 @@ async function executeCompleteFlow(applications: OAuthApplications, existingAlia } let identityToken: IdentityToken - const identityTokenInformation = getIdentityTokenInformation() + const identityTokenInformation = getIdentityTokenInformation(env) if (identityTokenInformation) { identityToken = buildIdentityTokenFromEnv(scopes, identityTokenInformation) } else { @@ -442,11 +455,11 @@ function getExchangeScopes(apps: OAuthApplications): ExchangeScopes { function buildIdentityTokenFromEnv( scopes: string[], - identityTokenInformation: {accessToken: string; refreshToken: string; userId: string}, + identityTokenInformation: {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date}, ) { return { ...identityTokenInformation, - expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + expiresAt: identityTokenInformation.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), scopes, alias: undefined, } diff --git a/packages/cli-kit/src/public/node/context/fqdn.test.ts b/packages/cli-kit/src/public/node/context/fqdn.test.ts index 72f51b35dd..2fbc21ab64 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.test.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.test.ts @@ -6,6 +6,7 @@ import { businessPlatformFqdn, appDevFqdn, adminFqdn, + previewStoreApiHost, } from './fqdn.js' import {Environment, serviceEnvironment} from '../../../private/node/context/service.js' import {expect, describe, test, vi} from 'vitest' @@ -218,3 +219,13 @@ describe('normalizeStore', () => { expect(got).toEqual('example.myshopify.com') }) }) + +describe('previewStoreApiHost', () => { + test('maps preview-store permanent domains to the local api host', () => { + expect(previewStoreApiHost('preview-shop.my.shop.dev')).toEqual('preview-shop.dev-api.shop.dev') + }) + + test('leaves non-preview-store domains unchanged', () => { + expect(previewStoreApiHost('shop.myshopify.com')).toEqual('shop.myshopify.com') + }) +}) diff --git a/packages/cli-kit/src/public/node/context/fqdn.ts b/packages/cli-kit/src/public/node/context/fqdn.ts index 85e6036bb1..f7c1bbeded 100644 --- a/packages/cli-kit/src/public/node/context/fqdn.ts +++ b/packages/cli-kit/src/public/node/context/fqdn.ts @@ -157,3 +157,18 @@ export function storeAdminUrl(storeFqdn: string): string { } return storeFqdn } + +/** + * Maps a preview-store permanent domain to the local Admin API host. + * Leaves all non-preview-store domains unchanged. + * + * @param storeFqdn - Normalized store FQDN. + * @returns Store API host for preview stores, otherwise the original FQDN. + */ +export function previewStoreApiHost(storeFqdn: string): string { + if (storeFqdn.endsWith('.my.shop.dev')) { + return storeFqdn.replace('.my.shop.dev', '.dev-api.shop.dev') + } + + return storeFqdn +} diff --git a/packages/cli-kit/src/public/node/environment.test.ts b/packages/cli-kit/src/public/node/environment.test.ts index 46003786f9..5c73be90a8 100644 --- a/packages/cli-kit/src/public/node/environment.test.ts +++ b/packages/cli-kit/src/public/node/environment.test.ts @@ -1,10 +1,14 @@ -import {getAppAutomationToken} from './environment.js' +import {getAppAutomationToken, getIdentityTokenInformation} from './environment.js' import {environmentVariables} from '../../private/node/constants.js' import {describe, expect, test, beforeEach} from 'vitest' beforeEach(() => { delete process.env[environmentVariables.appAutomationToken] delete process.env[environmentVariables.partnersToken] + delete process.env[environmentVariables.identityToken] + delete process.env[environmentVariables.refreshToken] + delete process.env[environmentVariables.identityTokenUserId] + delete process.env[environmentVariables.identityTokenExpiresAt] }) describe('getAppAutomationToken', () => { @@ -31,3 +35,38 @@ describe('getAppAutomationToken', () => { expect(getAppAutomationToken()).toBeUndefined() }) }) + +describe('getIdentityTokenInformation', () => { + test('returns undefined when either identity token or refresh token is missing', () => { + process.env[environmentVariables.identityToken] = 'identity-token' + + expect(getIdentityTokenInformation()).toBeUndefined() + }) + + test('uses explicit user id and expiry when provided', () => { + process.env[environmentVariables.identityToken] = 'identity-token' + process.env[environmentVariables.refreshToken] = 'refresh-token' + process.env[environmentVariables.identityTokenUserId] = 'placeholder-user-id' + process.env[environmentVariables.identityTokenExpiresAt] = '2026-05-14T12:00:00.000Z' + + expect(getIdentityTokenInformation()).toEqual({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + userId: 'placeholder-user-id', + expiresAt: new Date('2026-05-14T12:00:00.000Z'), + }) + }) + + test('falls back to hashing the identity token when no explicit user id is provided', () => { + process.env[environmentVariables.identityToken] = 'identity-token' + process.env[environmentVariables.refreshToken] = 'refresh-token' + + expect(getIdentityTokenInformation()).toEqual( + expect.objectContaining({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + }), + ) + expect(getIdentityTokenInformation()?.userId).toBeTruthy() + }) +}) diff --git a/packages/cli-kit/src/public/node/environment.ts b/packages/cli-kit/src/public/node/environment.ts index 844ae277b0..c015fa55ca 100644 --- a/packages/cli-kit/src/public/node/environment.ts +++ b/packages/cli-kit/src/public/node/environment.ts @@ -23,9 +23,8 @@ export function getEnvironmentVariables(): NodeJS.ProcessEnv { * * @returns The app automation token value, or undefined if neither env var is set. */ -export function getAppAutomationToken(): string | undefined { - const env = getEnvironmentVariables() - return env[environmentVariables.appAutomationToken] ?? env[environmentVariables.partnersToken] +export function getAppAutomationToken(environment = getEnvironmentVariables()): string | undefined { + return environment[environmentVariables.appAutomationToken] ?? environment[environmentVariables.partnersToken] } /** @@ -55,14 +54,21 @@ export function getBackendPort(): number | undefined { * * @returns The identity token information in case it exists. */ -export function getIdentityTokenInformation(): {accessToken: string; refreshToken: string; userId: string} | undefined { - const identityToken = getEnvironmentVariables()[environmentVariables.identityToken] - const refreshToken = getEnvironmentVariables()[environmentVariables.refreshToken] +export function getIdentityTokenInformation( + environment = getEnvironmentVariables(), +): {accessToken: string; refreshToken: string; userId: string; expiresAt?: Date} | undefined { + const identityToken = environment[environmentVariables.identityToken] + const refreshToken = environment[environmentVariables.refreshToken] if (!identityToken || !refreshToken) return undefined + + const expiresAtValue = environment[environmentVariables.identityTokenExpiresAt] + const expiresAt = expiresAtValue ? new Date(expiresAtValue) : undefined + return { accessToken: identityToken, refreshToken, - userId: nonRandomUUID(identityToken), + userId: environment[environmentVariables.identityTokenUserId] ?? nonRandomUUID(identityToken), + ...(expiresAt && !Number.isNaN(expiresAt.getTime()) ? {expiresAt} : {}), } } diff --git a/packages/cli-kit/src/public/node/session.test.ts b/packages/cli-kit/src/public/node/session.test.ts index 810f9acc1c..ec053a5d3d 100644 --- a/packages/cli-kit/src/public/node/session.test.ts +++ b/packages/cli-kit/src/public/node/session.test.ts @@ -6,6 +6,7 @@ import { ensureAuthenticatedPartners, ensureAuthenticatedStorefront, ensureAuthenticatedThemes, + importIdentitySession, setLastSeenUserId, } from './session.js' @@ -42,6 +43,31 @@ describe('store command analytics session helpers', () => { }) }) +describe('importIdentitySession', () => { + test('routes preview bootstrap through ensureAuthenticated with env bootstrap variables', async () => { + vi.mocked(ensureAuthenticated).mockResolvedValueOnce({userId: 'placeholder-user-id'}) + + const result = await importIdentitySession({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + expiresAt: new Date('2026-05-14T12:00:00.000Z'), + userId: 'placeholder-user-id', + }) + + expect(result).toEqual({userId: 'placeholder-user-id'}) + expect(ensureAuthenticated).toHaveBeenCalledWith( + {}, + expect.objectContaining({ + SHOPIFY_CLI_IDENTITY_TOKEN: 'identity-token', + SHOPIFY_CLI_REFRESH_TOKEN: 'refresh-token', + SHOPIFY_CLI_IDENTITY_USER_ID: 'placeholder-user-id', + SHOPIFY_CLI_IDENTITY_TOKEN_EXPIRES_AT: '2026-05-14T12:00:00.000Z', + }), + {noPrompt: true, forceNewSession: true}, + ) + }) +}) + describe('ensureAuthenticatedStorefront', () => { test('returns only storefront token if success', async () => { // Given diff --git a/packages/cli-kit/src/public/node/session.ts b/packages/cli-kit/src/public/node/session.ts index 1ebffdeaf4..a49e21d940 100644 --- a/packages/cli-kit/src/public/node/session.ts +++ b/packages/cli-kit/src/public/node/session.ts @@ -9,6 +9,7 @@ import { exchangeAppAutomationTokenForAppManagementAccessToken, exchangeAppAutomationTokenForBusinessPlatformAccessToken, } from '../../private/node/session/exchange.js' +import {environmentVariables} from '../../private/node/constants.js' import { AdminAPIScope, AppManagementAPIScope, @@ -51,6 +52,28 @@ export function setLastSeenUserId(userId: string): void { setLastSeenUserIdAfterAuth(userId) } +export interface IdentitySessionBootstrap { + accessToken: string + refreshToken: string + expiresAt: Date + userId?: string +} + +export async function importIdentitySession( + bootstrap: IdentitySessionBootstrap, + env = process.env, +): Promise<{userId: string}> { + const bootstrapEnv = { + ...env, + [environmentVariables.identityToken]: bootstrap.accessToken, + [environmentVariables.refreshToken]: bootstrap.refreshToken, + [environmentVariables.identityTokenExpiresAt]: bootstrap.expiresAt.toISOString(), + ...(bootstrap.userId ? {[environmentVariables.identityTokenUserId]: bootstrap.userId} : {}), + } + + return ensureAuthenticatedUser(bootstrapEnv, {noPrompt: true, forceNewSession: true}) +} + interface UserAccountInfo { type: 'UserAccount' email: string diff --git a/packages/cli/src/cli/commands/preview/execute.ts b/packages/cli/src/cli/commands/preview/execute.ts index 2585c76a6e..728aa1d542 100644 --- a/packages/cli/src/cli/commands/preview/execute.ts +++ b/packages/cli/src/cli/commands/preview/execute.ts @@ -6,12 +6,12 @@ import {Flags} from '@oclif/core' export default class PreviewStoreExecute extends Command { static summary = 'Run an Admin GraphQL operation against a Preview Store using its shop-scoped token.' - static description = `Reads the shop domain and admin API token straight from \`preview create --json\` output (or accepts them as flags). Calls the Admin GraphQL endpoint at the requested API version. Mutations are blocked unless --allow-mutations is set.` + static description = `Reads the shop domain and admin API token straight from \`preview create --json\` output (or accepts them as flags). For local preview stores, the human-facing \`*.my.shop.dev\` domain is accepted and routed to the Admin API host automatically. Mutations are blocked unless --allow-mutations is set.` static examples = [ '<%= config.bin %> <%= command.id %> --from-file /tmp/preview.json --query "{ shop { name } }"', '<%= config.bin %> <%= command.id %> --from-file /tmp/preview.json --query-file ./query.graphql', - '<%= config.bin %> <%= command.id %> --domain shop.myshopify.io --token shpat_... --query "..."', + '<%= config.bin %> <%= command.id %> --domain preview-123.my.shop.dev --token shpat_... --query "..."', '<%= config.bin %> <%= command.id %> --from-file /tmp/preview.json --allow-mutations --query-file ./mutation.graphql', ] @@ -25,7 +25,7 @@ export default class PreviewStoreExecute extends Command { exclusive: ['domain', 'token'], }), domain: Flags.string({ - description: 'Permanent shop domain (e.g. preview-123.myshopify.io). Required if --from-file is omitted.', + description: 'Shop domain to use for Admin API requests. For local preview stores, the permanent *.my.shop.dev domain is accepted and routed automatically. Required if --from-file is omitted.', env: 'SHOPIFY_FLAG_PREVIEW_STORE_DOMAIN', required: false, dependsOn: ['token'], diff --git a/packages/cli/src/cli/services/commands/preview/bootstrap.test.ts b/packages/cli/src/cli/services/commands/preview/bootstrap.test.ts new file mode 100644 index 0000000000..9ed93c5351 --- /dev/null +++ b/packages/cli/src/cli/services/commands/preview/bootstrap.test.ts @@ -0,0 +1,116 @@ +import {importPreviewStoreBootstrap} from './bootstrap.js' +import {beforeEach, describe, expect, test, vi} from 'vitest' +import {importIdentitySession} from '@shopify/cli-kit/node/session' +import {importStoreAuthBootstrap} from '@shopify/store' + +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/store', () => ({ + importStoreAuthBootstrap: vi.fn(), +})) + +describe('importPreviewStoreBootstrap', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + test('imports both CLI identity and stored store auth from preview create response', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-14T12:00:00.000Z')) + vi.mocked(importIdentitySession).mockResolvedValueOnce({userId: 'placeholder-user-id'}) + + const result = await importPreviewStoreBootstrap({ + shop_id: 1, + shop_permanent_domain: 'preview-shop.my.shop.dev', + placeholder_account_uuid: 'placeholder-user-id', + admin_api_token: 'admin-token', + magic_link_url: 'https://example.com/magic-link', + cli_identity_bootstrap: { + access_token: 'identity-token', + refresh_token: 'refresh-token', + expires_in: 1800, + user_id: 'placeholder-user-id', + }, + store_auth_bootstrap: { + access_token: 'store-token', + scopes: ['read_products'], + api_key: 'development-shop-merchant-key', + shop_domain: 'preview-shop.dev-api.shop.dev', + }, + }) + + expect(result).toEqual({identityImported: true, storeAuthImported: true}) + expect(importIdentitySession).toHaveBeenCalledWith({ + accessToken: 'identity-token', + refreshToken: 'refresh-token', + expiresAt: new Date('2026-05-14T12:30:00.000Z'), + userId: 'placeholder-user-id', + }) + expect(importStoreAuthBootstrap).toHaveBeenCalledWith({ + userId: 'placeholder-user-id', + bootstrap: { + accessToken: 'store-token', + scopes: ['read_products'], + apiKey: 'development-shop-merchant-key', + shopDomain: 'preview-shop.dev-api.shop.dev', + }, + }) + + vi.useRealTimers() + }) + + test('falls back to the placeholder account uuid when backend omits cli_identity_bootstrap.user_id', async () => { + vi.mocked(importIdentitySession).mockResolvedValueOnce({userId: 'placeholder-uuid'}) + + await importPreviewStoreBootstrap({ + shop_id: 1, + shop_permanent_domain: 'preview-shop.my.shop.dev', + placeholder_account_uuid: 'placeholder-uuid', + admin_api_token: 'admin-token', + magic_link_url: 'https://example.com/magic-link', + cli_identity_bootstrap: { + access_token: 'identity-token', + refresh_token: 'refresh-token', + expires_in: 1800, + }, + }) + + expect(importIdentitySession).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'placeholder-uuid', + }), + ) + }) + + test('rejects invalid identity bootstrap expiry values', async () => { + await expect( + importPreviewStoreBootstrap({ + shop_id: 1, + shop_permanent_domain: 'preview-shop.my.shop.dev', + placeholder_account_uuid: 'placeholder-user-id', + admin_api_token: 'admin-token', + magic_link_url: 'https://example.com/magic-link', + cli_identity_bootstrap: { + access_token: 'identity-token', + refresh_token: 'refresh-token', + expires_in: 0, + user_id: 'placeholder-user-id', + }, + }), + ).rejects.toThrow('Preview store returned an invalid CLI identity bootstrap expiry.') + }) + + test('does nothing when preview create returns no bootstrap payloads', async () => { + const result = await importPreviewStoreBootstrap({ + shop_id: 1, + shop_permanent_domain: 'preview-shop.my.shop.dev', + placeholder_account_uuid: 'placeholder-user-id', + admin_api_token: 'admin-token', + magic_link_url: 'https://example.com/magic-link', + }) + + expect(result).toEqual({identityImported: false, storeAuthImported: false}) + expect(importIdentitySession).not.toHaveBeenCalled() + expect(importStoreAuthBootstrap).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/cli/services/commands/preview/bootstrap.ts b/packages/cli/src/cli/services/commands/preview/bootstrap.ts new file mode 100644 index 0000000000..4288fcd690 --- /dev/null +++ b/packages/cli/src/cli/services/commands/preview/bootstrap.ts @@ -0,0 +1,59 @@ +import {AbortError} from '@shopify/cli-kit/node/error' +import {importIdentitySession} from '@shopify/cli-kit/node/session' +import {importStoreAuthBootstrap} from '@shopify/store' +import type {PreviewStoreCreateResponse} from './client.js' + +const MAX_BOOTSTRAP_IDENTITY_LIFETIME_SECONDS = 365 * 24 * 60 * 60 + +function identityBootstrapExpiresAt(expiresInSeconds: number): Date { + if (!Number.isFinite(expiresInSeconds) || expiresInSeconds <= 0 || expiresInSeconds > MAX_BOOTSTRAP_IDENTITY_LIFETIME_SECONDS) { + throw new AbortError('Preview store returned an invalid CLI identity bootstrap expiry.') + } + + return new Date(Date.now() + expiresInSeconds * 1000) +} + +export async function importPreviewStoreBootstrap( + response: PreviewStoreCreateResponse, +): Promise<{identityImported: boolean; storeAuthImported: boolean}> { + const cliIdentityBootstrap = response.cli_identity_bootstrap + const storeAuthBootstrap = response.store_auth_bootstrap + + if (!cliIdentityBootstrap && !storeAuthBootstrap) { + return {identityImported: false, storeAuthImported: false} + } + + const bootstrapUserId = cliIdentityBootstrap?.user_id ?? response.placeholder_account_uuid + + let userId = bootstrapUserId + if (cliIdentityBootstrap) { + const importedIdentity = await importIdentitySession({ + accessToken: cliIdentityBootstrap.access_token, + refreshToken: cliIdentityBootstrap.refresh_token, + expiresAt: identityBootstrapExpiresAt(cliIdentityBootstrap.expires_in), + userId: bootstrapUserId, + }) + userId = importedIdentity.userId + } + + if (storeAuthBootstrap) { + // Keep the stored auth keyed to the backend-provided API host for this + // prototype. Human-facing preview-store domains are mapped at the command + // boundary (`previewStoreApiHost`) so the working transport shape stays + // explicit in persisted store auth. + importStoreAuthBootstrap({ + userId, + bootstrap: { + accessToken: storeAuthBootstrap.access_token, + scopes: storeAuthBootstrap.scopes, + apiKey: storeAuthBootstrap.api_key, + shopDomain: storeAuthBootstrap.shop_domain, + }, + }) + } + + return { + identityImported: Boolean(cliIdentityBootstrap), + storeAuthImported: Boolean(storeAuthBootstrap), + } +} diff --git a/packages/cli/src/cli/services/commands/preview/client.ts b/packages/cli/src/cli/services/commands/preview/client.ts index 90b4b1e740..c3d4f3906e 100644 --- a/packages/cli/src/cli/services/commands/preview/client.ts +++ b/packages/cli/src/cli/services/commands/preview/client.ts @@ -3,12 +3,28 @@ 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' +export interface PreviewCliIdentityBootstrap { + access_token: string + refresh_token: string + expires_in: number + user_id?: string +} + +export interface PreviewStoreAuthBootstrap { + access_token: string + scopes: string[] + api_key: string + shop_domain: string +} + export interface PreviewStoreCreateResponse { shop_id: number shop_permanent_domain: string placeholder_account_uuid: string admin_api_token: string magic_link_url: string + cli_identity_bootstrap?: PreviewCliIdentityBootstrap + store_auth_bootstrap?: PreviewStoreAuthBootstrap } export interface PreviewStoreClaimResponse { diff --git a/packages/cli/src/cli/services/commands/preview/create.test.ts b/packages/cli/src/cli/services/commands/preview/create.test.ts new file mode 100644 index 0000000000..f512c2cfd7 --- /dev/null +++ b/packages/cli/src/cli/services/commands/preview/create.test.ts @@ -0,0 +1,67 @@ +import {createPreviewStoreCommand} from './create.js' +import {createPreviewStore} from './client.js' +import {importPreviewStoreBootstrap} from './bootstrap.js' +import {outputResult} from '@shopify/cli-kit/node/output' +import {renderSuccess} from '@shopify/cli-kit/node/ui' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./client.js') +vi.mock('./bootstrap.js') +vi.mock('@shopify/cli-kit/node/output') +vi.mock('@shopify/cli-kit/node/ui') + +const previewResponse = { + shop_id: 1, + shop_permanent_domain: 'preview-shop.my.shop.dev', + placeholder_account_uuid: 'placeholder-user-id', + admin_api_token: 'admin-token', + magic_link_url: 'https://example.com/magic-link', + cli_identity_bootstrap: { + access_token: 'identity-token', + refresh_token: 'refresh-token', + expires_in: 1800, + user_id: 'placeholder-user-id', + }, + store_auth_bootstrap: { + access_token: 'store-token', + scopes: ['read_products'], + api_key: 'development-shop-merchant-key', + shop_domain: 'preview-shop.dev-api.shop.dev', + }, +} + +describe('createPreviewStoreCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(createPreviewStore).mockResolvedValue(previewResponse) + vi.mocked(importPreviewStoreBootstrap).mockResolvedValue({identityImported: true, storeAuthImported: true}) + }) + + test('imports bootstrap payloads before rendering text output', async () => { + await createPreviewStoreCommand({ + shopName: 'preview-shop', + json: false, + }) + + expect(createPreviewStore).toHaveBeenCalled() + expect(importPreviewStoreBootstrap).toHaveBeenCalledWith(previewResponse) + expect(renderSuccess).toHaveBeenCalled() + const renderCall = vi.mocked(renderSuccess).mock.calls[0]?.[0] + expect(renderCall?.nextSteps).toContainEqual([ + 'Run an Admin GraphQL query:', + {command: "shopify preview execute --domain preview-shop.my.shop.dev --token admin-token --query '{ shop { name } }'"}, + ]) + expect(outputResult).not.toHaveBeenCalled() + }) + + test('imports bootstrap payloads before emitting json output', async () => { + await createPreviewStoreCommand({ + shopName: 'preview-shop', + json: true, + }) + + expect(importPreviewStoreBootstrap).toHaveBeenCalledWith(previewResponse) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify(previewResponse, null, 2)) + expect(renderSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/packages/cli/src/cli/services/commands/preview/create.ts b/packages/cli/src/cli/services/commands/preview/create.ts index 5779c44fbd..359bdca4e1 100644 --- a/packages/cli/src/cli/services/commands/preview/create.ts +++ b/packages/cli/src/cli/services/commands/preview/create.ts @@ -4,6 +4,7 @@ import { createPreviewStore, defaultClientOptions, } from './client.js' +import {importPreviewStoreBootstrap} from './bootstrap.js' import {outputResult} from '@shopify/cli-kit/node/output' import {renderSuccess} from '@shopify/cli-kit/node/ui' @@ -26,6 +27,8 @@ export async function createPreviewStoreCommand(input: CreatePreviewStoreInput): options, ) + await importPreviewStoreBootstrap(response) + if (input.json) { outputResult(JSON.stringify(response, null, 2)) return diff --git a/packages/cli/src/cli/services/commands/preview/execute.test.ts b/packages/cli/src/cli/services/commands/preview/execute.test.ts new file mode 100644 index 0000000000..a01d27cfc6 --- /dev/null +++ b/packages/cli/src/cli/services/commands/preview/execute.test.ts @@ -0,0 +1,78 @@ +import {executePreviewStoreCommand} from './execute.js' +import {executePreviewStoreAdminQuery} from './client.js' +import {fileExists, readFile} from '@shopify/cli-kit/node/fs' +import {outputResult} from '@shopify/cli-kit/node/output' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('./client.js') +vi.mock('@shopify/cli-kit/node/fs') +vi.mock('@shopify/cli-kit/node/output') + +describe('executePreviewStoreCommand', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fileExists).mockResolvedValue(true) + vi.mocked(executePreviewStoreAdminQuery).mockResolvedValue({data: {shop: {name: 'Preview Shop'}}}) + }) + + test('prefers store_auth_bootstrap.shop_domain when reading preview create json', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({ + shop_permanent_domain: 'preview-shop.my.shop.dev', + admin_api_token: 'admin-token', + store_auth_bootstrap: { + shop_domain: 'preview-shop.dev-api.shop.dev', + }, + }))) + + await executePreviewStoreCommand({ + fromFile: '/tmp/preview.json', + query: 'query { shop { name } }', + apiVersion: 'unstable', + allowMutations: false, + json: true, + }) + + expect(executePreviewStoreAdminQuery).toHaveBeenCalledWith({ + domain: 'preview-shop.dev-api.shop.dev', + token: 'admin-token', + apiVersion: 'unstable', + query: 'query { shop { name } }', + variables: undefined, + }) + expect(outputResult).toHaveBeenCalledWith(JSON.stringify({data: {shop: {name: 'Preview Shop'}}}, null, 0)) + }) + + test('falls back to shop_permanent_domain for older preview create json', async () => { + vi.mocked(readFile).mockResolvedValue(Buffer.from(JSON.stringify({ + shop_permanent_domain: 'preview-shop.myshopify.com', + admin_api_token: 'admin-token', + }))) + + await executePreviewStoreCommand({ + fromFile: '/tmp/preview.json', + query: 'query { shop { name } }', + apiVersion: 'unstable', + allowMutations: false, + json: true, + }) + + expect(executePreviewStoreAdminQuery).toHaveBeenCalledWith(expect.objectContaining({ + domain: 'preview-shop.myshopify.com', + })) + }) + + test('maps preview-store permanent domains to the api host for manual input', async () => { + await executePreviewStoreCommand({ + domain: 'preview-shop.my.shop.dev', + token: 'admin-token', + query: 'query { shop { name } }', + apiVersion: 'unstable', + allowMutations: false, + json: true, + }) + + expect(executePreviewStoreAdminQuery).toHaveBeenCalledWith(expect.objectContaining({ + domain: 'preview-shop.dev-api.shop.dev', + })) + }) +}) diff --git a/packages/cli/src/cli/services/commands/preview/execute.ts b/packages/cli/src/cli/services/commands/preview/execute.ts index 35cd7fcf1b..34724be193 100644 --- a/packages/cli/src/cli/services/commands/preview/execute.ts +++ b/packages/cli/src/cli/services/commands/preview/execute.ts @@ -1,9 +1,16 @@ import {executePreviewStoreAdminQuery} from './client.js' +import {previewStoreApiHost} from '@shopify/cli-kit/node/context/fqdn' import {AbortError} from '@shopify/cli-kit/node/error' import {fileExists, readFile} from '@shopify/cli-kit/node/fs' import {outputResult} from '@shopify/cli-kit/node/output' import {OperationDefinitionNode, parse} from 'graphql' +interface PreviewCreateJson { + shop_permanent_domain?: unknown + admin_api_token?: unknown + store_auth_bootstrap?: {shop_domain?: unknown} +} + export interface ExecutePreviewStoreInput { domain?: string token?: string @@ -41,22 +48,27 @@ async function resolveAuth(input: ExecutePreviewStoreInput): Promise<{domain: st throw new AbortError(`File not found: ${input.fromFile}`) } const raw = await readFile(input.fromFile, {encoding: 'utf8'}) - let parsed: {shop_permanent_domain?: unknown; admin_api_token?: unknown} + let parsed: PreviewCreateJson try { parsed = JSON.parse(raw) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' throw new AbortError(`Invalid JSON in ${input.fromFile}: ${message}`) } - const domain = typeof parsed.shop_permanent_domain === 'string' ? parsed.shop_permanent_domain : undefined + // Prefer the backend-provided API host when present; otherwise fall back + // to the human-facing permanent domain from older `preview create` output. + const bootstrapDomain = typeof parsed.store_auth_bootstrap?.shop_domain === 'string' + ? parsed.store_auth_bootstrap.shop_domain + : undefined + const domain = bootstrapDomain ?? (typeof parsed.shop_permanent_domain === 'string' ? parsed.shop_permanent_domain : undefined) const token = typeof parsed.admin_api_token === 'string' ? parsed.admin_api_token : undefined if (!domain || !token) { throw new AbortError( - `File ${input.fromFile} is missing shop_permanent_domain or admin_api_token.`, + `File ${input.fromFile} is missing a shop domain or admin_api_token.`, 'Re-run `shopify preview create --json` and tee the output to this file.', ) } - return {domain, token} + return {domain: previewStoreApiHost(domain), token} } if (!input.domain || !input.token) { @@ -64,7 +76,7 @@ async function resolveAuth(input: ExecutePreviewStoreInput): Promise<{domain: st 'Provide --from-file (JSON output from `preview create`) or both --domain and --token.', ) } - return {domain: input.domain, token: input.token} + return {domain: previewStoreApiHost(input.domain), token: input.token} } async function readQuery(input: ExecutePreviewStoreInput): Promise { diff --git a/packages/store/src/cli/commands/store/auth.test.ts b/packages/store/src/cli/commands/store/auth.test.ts index 2b3e0efa85..8672c04d6b 100644 --- a/packages/store/src/cli/commands/store/auth.test.ts +++ b/packages/store/src/cli/commands/store/auth.test.ts @@ -36,6 +36,17 @@ describe('store auth command', () => { ) }) + test('maps preview-store permanent domains to the api host before calling the auth service', async () => { + await StoreAuth.run(['--store', 'preview-shop.my.shop.dev', '--scopes', 'read_products']) + + expect(authenticateStoreWithApp).toHaveBeenCalledWith( + expect.objectContaining({ + store: 'preview-shop.dev-api.shop.dev', + }), + expect.anything(), + ) + }) + test('defines the expected flags', () => { expect(StoreAuth.flags.store).toBeDefined() expect(StoreAuth.flags.scopes).toBeDefined() diff --git a/packages/store/src/cli/commands/store/auth.ts b/packages/store/src/cli/commands/store/auth.ts index 9a7c0eb2fd..afddf6c1ea 100644 --- a/packages/store/src/cli/commands/store/auth.ts +++ b/packages/store/src/cli/commands/store/auth.ts @@ -2,7 +2,7 @@ import {authenticateStoreWithApp} from '../../services/store/auth/index.js' import {createStoreAuthPresenter} from '../../services/store/auth/result.js' import StoreCommand from '../../utilities/store-command.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {normalizeStoreFqdn, previewStoreApiHost} from '@shopify/cli-kit/node/context/fqdn' import {Flags} from '@oclif/core' export default class StoreAuth extends StoreCommand { @@ -24,9 +24,9 @@ Re-run this command if the stored token is missing, expires, or no longer has th ...jsonFlag, store: Flags.string({ char: 's', - description: 'The myshopify.com domain of the store to authenticate against.', + description: 'The myshopify.com domain of the store to authenticate against. Local preview-store *.my.shop.dev domains are also accepted and routed automatically.', env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), + parse: async (input) => previewStoreApiHost(normalizeStoreFqdn(input)), required: true, }), scopes: Flags.string({ diff --git a/packages/store/src/cli/commands/store/execute.test.ts b/packages/store/src/cli/commands/store/execute.test.ts index 1871829962..1a11ac5a64 100644 --- a/packages/store/src/cli/commands/store/execute.test.ts +++ b/packages/store/src/cli/commands/store/execute.test.ts @@ -45,6 +45,14 @@ describe('store execute command', () => { expect(writeOrOutputStoreExecuteResult).toHaveBeenCalledWith({data: {shop: {name: 'Test shop'}}}, undefined, 'json') }) + test('maps preview-store permanent domains to the api host before calling the execute service', async () => { + await StoreExecute.run(['--store', 'preview-shop.my.shop.dev', '--query', 'query { shop { name } }']) + + expect(executeStoreOperation).toHaveBeenCalledWith(expect.objectContaining({ + store: 'preview-shop.dev-api.shop.dev', + })) + }) + test('defines the expected flags', () => { expect(StoreExecute.flags.store).toBeDefined() expect(StoreExecute.flags.query).toBeDefined() diff --git a/packages/store/src/cli/commands/store/execute.ts b/packages/store/src/cli/commands/store/execute.ts index e3f0bd3fc3..ee76677d91 100644 --- a/packages/store/src/cli/commands/store/execute.ts +++ b/packages/store/src/cli/commands/store/execute.ts @@ -2,7 +2,7 @@ import {executeStoreOperation} from '../../services/store/execute/index.js' import {writeOrOutputStoreExecuteResult} from '../../services/store/execute/result.js' import StoreCommand from '../../utilities/store-command.js' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {normalizeStoreFqdn, previewStoreApiHost} from '@shopify/cli-kit/node/context/fqdn' import {resolvePath} from '@shopify/cli-kit/node/path' import {Flags} from '@oclif/core' @@ -54,9 +54,9 @@ Mutations are disabled by default. Re-run with \`--allow-mutations\` if you inte }), store: Flags.string({ char: 's', - description: 'The myshopify.com domain of the store to execute against.', + description: 'The myshopify.com domain of the store to execute against. Local preview-store *.my.shop.dev domains are also accepted and routed automatically.', env: 'SHOPIFY_FLAG_STORE', - parse: async (input) => normalizeStoreFqdn(input), + parse: async (input) => previewStoreApiHost(normalizeStoreFqdn(input)), required: true, }), version: Flags.string({ diff --git a/packages/store/src/cli/services/store/auth/bootstrap.test.ts b/packages/store/src/cli/services/store/auth/bootstrap.test.ts new file mode 100644 index 0000000000..988aaa1bbb --- /dev/null +++ b/packages/store/src/cli/services/store/auth/bootstrap.test.ts @@ -0,0 +1,58 @@ +import {importStoreAuthBootstrap} from './bootstrap.js' +import {setStoredStoreAppSession} from './session-store.js' +import {describe, expect, test, vi} from 'vitest' + +vi.mock('./session-store.js') + +describe('importStoreAuthBootstrap', () => { + test('persists the provided store auth bootstrap through the normal store session seam', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-14T12:00:00.000Z')) + + importStoreAuthBootstrap({ + userId: 'placeholder-user-id', + bootstrap: { + accessToken: 'store-access-token', + scopes: ['read_products'], + apiKey: 'development-shop-merchant-key', + shopDomain: 'preview-shop', + }, + }) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith({ + store: 'preview-shop.myshopify.com', + clientId: 'development-shop-merchant-key', + userId: 'placeholder-user-id', + accessToken: 'store-access-token', + refreshToken: undefined, + scopes: ['read_products'], + acquiredAt: '2026-05-14T12:00:00.000Z', + expiresAt: undefined, + refreshTokenExpiresAt: undefined, + associatedUser: undefined, + }) + + vi.useRealTimers() + }) + + test('preserves fully qualified preview api hosts', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-14T12:00:00.000Z')) + + importStoreAuthBootstrap({ + userId: 'placeholder-user-id', + bootstrap: { + accessToken: 'store-access-token', + scopes: ['read_products'], + apiKey: 'development-shop-merchant-key', + shopDomain: 'preview-shop.dev-api.shop.dev', + }, + }) + + expect(setStoredStoreAppSession).toHaveBeenCalledWith(expect.objectContaining({ + store: 'preview-shop.dev-api.shop.dev', + })) + + vi.useRealTimers() + }) +}) diff --git a/packages/store/src/cli/services/store/auth/bootstrap.ts b/packages/store/src/cli/services/store/auth/bootstrap.ts new file mode 100644 index 0000000000..317c18aaba --- /dev/null +++ b/packages/store/src/cli/services/store/auth/bootstrap.ts @@ -0,0 +1,39 @@ +import {setStoredStoreAppSession} from './session-store.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' + +export interface StoreAuthBootstrap { + accessToken: string + scopes: string[] + apiKey: string + shopDomain: string + refreshToken?: string + expiresIn?: number + refreshTokenExpiresIn?: number + associatedUser?: { + id: number + email?: string + firstName?: string + lastName?: string + accountOwner?: boolean + } +} + +export function importStoreAuthBootstrap(input: {userId: string; bootstrap: StoreAuthBootstrap}): void { + const now = Date.now() + const {bootstrap, userId} = input + + setStoredStoreAppSession({ + store: normalizeStoreFqdn(bootstrap.shopDomain), + clientId: bootstrap.apiKey, + userId, + accessToken: bootstrap.accessToken, + refreshToken: bootstrap.refreshToken, + scopes: bootstrap.scopes, + acquiredAt: new Date(now).toISOString(), + expiresAt: bootstrap.expiresIn ? new Date(now + bootstrap.expiresIn * 1000).toISOString() : undefined, + refreshTokenExpiresAt: bootstrap.refreshTokenExpiresIn + ? new Date(now + bootstrap.refreshTokenExpiresIn * 1000).toISOString() + : undefined, + associatedUser: bootstrap.associatedUser, + }) +} diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 73e67d7815..57b8fdd3b9 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -1,6 +1,8 @@ import StoreAuth from './cli/commands/store/auth.js' import StoreExecute from './cli/commands/store/execute.js' +export {importStoreAuthBootstrap, type StoreAuthBootstrap} from './cli/services/store/auth/bootstrap.js' + const COMMANDS = { 'store:auth': StoreAuth, 'store:execute': StoreExecute,