From a1633f26809bc1d08107f1944434518123d5ae08 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 11 Jun 2026 13:59:38 -0700 Subject: [PATCH 1/5] Redact signature headers from debug logs Crawler signature values are request secrets, so verbose request logging should treat Signature, Signature-Input, and Signature-Agent like other sensitive headers before the dev server starts sending them. --- packages/cli-kit/src/private/node/api/headers.test.ts | 3 +++ packages/cli-kit/src/private/node/api/headers.ts | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/cli-kit/src/private/node/api/headers.test.ts b/packages/cli-kit/src/private/node/api/headers.test.ts index 2588dc77196..c27668fc7ad 100644 --- a/packages/cli-kit/src/private/node/api/headers.test.ts +++ b/packages/cli-kit/src/private/node/api/headers.test.ts @@ -87,6 +87,9 @@ describe('common API methods', () => { 'X-Shopify-Access-Token': 'token', Cookie: 'session=123', 'Set-Cookie': 'session=456', + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', } // When diff --git a/packages/cli-kit/src/private/node/api/headers.ts b/packages/cli-kit/src/private/node/api/headers.ts index 158f00b8c8f..47095a323ce 100644 --- a/packages/cli-kit/src/private/node/api/headers.ts +++ b/packages/cli-kit/src/private/node/api/headers.ts @@ -26,7 +26,7 @@ export class GraphQLClientError extends RequestClientError { } } -const SENSITIVE_HEADERS = ['token', 'authorization', 'subject_token', 'cookie'] +const SENSITIVE_HEADERS = ['token', 'authorization', 'subject_token', 'cookie', 'signature'] /** * Removes the sensitive data from the headers and outputs them as a string. From aa67d46d8c29a44615baea7a062c7a9ece0909aa Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 11 Jun 2026 13:59:44 -0700 Subject: [PATCH 2/5] Allow Admin requests to specify API version Some internal Admin GraphQL fields are only available on unstable. Let raw Admin requests opt into a specific version while preserving the existing latest-supported-version behavior by default. --- .../cli-kit/src/public/node/api/admin.test.ts | 21 +++++++++++++++++++ packages/cli-kit/src/public/node/api/admin.ts | 12 ++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cli-kit/src/public/node/api/admin.test.ts b/packages/cli-kit/src/public/node/api/admin.test.ts index 00b4bf7b96e..1a503448dfd 100644 --- a/packages/cli-kit/src/public/node/api/admin.test.ts +++ b/packages/cli-kit/src/public/node/api/admin.test.ts @@ -64,6 +64,27 @@ describe('admin-graphql-api', () => { }) }) + test('request uses the provided API version when specified', async () => { + // Given + vi.mocked(graphqlRequest).mockResolvedValue({}) + vi.mocked(graphqlRequestDoc).mockResolvedValue(mockedResult) + + // When + await admin.adminRequest('query', Session, {variables: 'variables'}, 'unstable') + + // Then + expect(graphqlRequestDoc).not.toHaveBeenCalled() + expect(graphqlRequest).toHaveBeenCalledOnce() + expect(graphqlRequest).toHaveBeenCalledWith({ + query: 'query', + api: 'Admin', + url: 'https://store.myshopify.com/admin/api/unstable/graphql.json', + addedHeaders: {}, + token, + variables: {variables: 'variables'}, + }) + }) + test('request is called with correct parameters when it is a theme access session', async () => { // Given const themeAccessToken = 'shptka_token' diff --git a/packages/cli-kit/src/public/node/api/admin.ts b/packages/cli-kit/src/public/node/api/admin.ts index d121ea3b320..f496b9d533b 100644 --- a/packages/cli-kit/src/public/node/api/admin.ts +++ b/packages/cli-kit/src/public/node/api/admin.ts @@ -32,11 +32,17 @@ const LatestApiVersionByFQDN = new Map() * @param query - GraphQL query to execute. * @param session - Shopify admin session including token and Store FQDN. * @param variables - GraphQL variables to pass to the query. + * @param version - API version. * @returns The response of the query of generic type . */ -export async function adminRequest(query: string, session: AdminSession, variables?: GraphQLVariables): Promise { +export async function adminRequest( + query: string, + session: AdminSession, + variables?: GraphQLVariables, + version?: string, +): Promise { const api = 'Admin' - const version = await fetchLatestSupportedApiVersion(session) + const apiVersion = version ?? (await fetchLatestSupportedApiVersion(session)) let storeDomain = session.storeFqdn const addedHeaders = themeAccessHeaders(session) @@ -45,7 +51,7 @@ export async function adminRequest(query: string, session: AdminSession, vari storeDomain = new DevServerCore().host('app') } - const url = adminUrl(storeDomain, version, session) + const url = adminUrl(storeDomain, apiVersion, session) return graphqlRequest({query, api, addedHeaders, url, token: session.token, variables}) } From 3524f1bba0b717cdd01ad658706d1ea097c90194 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 11 Jun 2026 13:59:51 -0700 Subject: [PATCH 3/5] Fetch Shopify CLI crawler signatures from Admin Add a theme helper that reuses an active CLI-managed crawler signature for the storefront domain or creates one through the internal Admin mutation when needed. The helper returns only the request headers the dev server needs. --- .../crawler-signature.test.ts | 215 +++++++++++++++++ .../theme-environment/crawler-signature.ts | 220 ++++++++++++++++++ 2 files changed, 435 insertions(+) create mode 100644 packages/theme/src/cli/utilities/theme-environment/crawler-signature.test.ts create mode 100644 packages/theme/src/cli/utilities/theme-environment/crawler-signature.ts diff --git a/packages/theme/src/cli/utilities/theme-environment/crawler-signature.test.ts b/packages/theme/src/cli/utilities/theme-environment/crawler-signature.test.ts new file mode 100644 index 00000000000..b0791666fee --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/crawler-signature.test.ts @@ -0,0 +1,215 @@ +import { + CRAWLER_SIGNATURE_NAME, + CRAWLER_SIGNATURE_TTL_SECONDS, + crawlerSignatureHeaderDebugSummary, + fetchOrCreateCrawlerSignatureHeaders, +} from './crawler-signature.js' +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {outputDebug} from '@shopify/cli-kit/node/output' +import {describe, expect, test, vi, beforeEach} from 'vitest' + +vi.mock('@shopify/cli-kit/node/api/admin') +vi.mock('@shopify/cli-kit/node/output', async (realImport) => { + const realModule = await realImport() + return { + ...realModule, + outputDebug: vi.fn(), + } +}) +vi.mock('@shopify/cli-kit/node/analytics', () => ({ + recordEvent: vi.fn(), + recordError: vi.fn((error) => error), +})) + +const adminSession = { + token: 'admin-token', + storeFqdn: 'store.myshopify.com', +} + +const crawlerSignature = { + id: 'gid://shopify/StorefrontCrawlerSignature/1', + name: CRAWLER_SIGNATURE_NAME, + domainHost: 'store.myshopify.com', + signature: 'signature-value', + signatureInput: 'signature-input-value', + signatureAgent: 'signature-agent-value', + expiresAt: '2026-07-01T00:00:00Z', +} + +const emptyCrawlerSignaturesResponse = { + storefrontCrawlerSignatures: { + edges: [], + }, +} + +describe('crawlerSignatureHeaderDebugSummary', () => { + test('reports only crawler signature header names as present or missing', () => { + const summary = crawlerSignatureHeaderDebugSummary({ + Signature: 'secret-signature-value', + 'Signature-Agent': 'secret-signature-agent-value', + Authorization: 'secret-token', + }) + + expect(summary).toBe('present: Signature, Signature-Agent; missing: Signature-Input') + expect(summary).not.toContain('secret') + expect(summary).not.toContain('Authorization') + }) +}) + +describe('fetchOrCreateCrawlerSignatureHeaders', () => { + beforeEach(() => { + vi.mocked(adminRequest).mockReset() + vi.mocked(outputDebug).mockReset() + }) + + test('reuses an active Shopify CLI crawler signature for the store domain', async () => { + vi.mocked(adminRequest).mockResolvedValueOnce({ + storefrontCrawlerSignatures: { + edges: [ + { + node: crawlerSignature, + }, + ], + }, + }) + + const headers = await fetchOrCreateCrawlerSignatureHeaders(adminSession) + + expect(headers).toEqual({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }) + expect(adminRequest).toHaveBeenCalledTimes(1) + expect(adminRequest).toHaveBeenCalledWith( + expect.stringContaining('storefrontCrawlerSignatures'), + adminSession, + { + first: 1, + expired: false, + cli: true, + domain: 'store.myshopify.com', + }, + 'unstable', + ) + }) + + test('creates a Shopify CLI crawler signature when no reusable signature exists', async () => { + vi.mocked(adminRequest) + .mockResolvedValueOnce(emptyCrawlerSignaturesResponse) + .mockResolvedValueOnce({ + storefrontCrawlerSignatureGenerate: { + ...crawlerSignature, + userErrors: [], + }, + }) + + const headers = await fetchOrCreateCrawlerSignatureHeaders(adminSession) + + expect(headers).toEqual({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }) + expect(adminRequest).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('storefrontCrawlerSignatureGenerate'), + adminSession, + { + timeToLive: CRAWLER_SIGNATURE_TTL_SECONDS, + name: CRAWLER_SIGNATURE_NAME, + domainHost: 'store.myshopify.com', + cli: true, + }, + 'unstable', + ) + }) + + test('queries and creates signatures for a custom domain', async () => { + vi.mocked(adminRequest) + .mockResolvedValueOnce(emptyCrawlerSignaturesResponse) + .mockResolvedValueOnce({ + storefrontCrawlerSignatureGenerate: { + ...crawlerSignature, + domainHost: 'custom.example.com', + userErrors: [], + }, + }) + + await fetchOrCreateCrawlerSignatureHeaders(adminSession, 'custom.example.com') + + expect(adminRequest).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('storefrontCrawlerSignatures'), + adminSession, + expect.objectContaining({domain: 'custom.example.com'}), + 'unstable', + ) + expect(adminRequest).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('storefrontCrawlerSignatureGenerate'), + adminSession, + expect.objectContaining({domainHost: 'custom.example.com'}), + 'unstable', + ) + }) + + test('continues without crawler signature headers when the query fails to return a connection', async () => { + vi.mocked(adminRequest).mockResolvedValueOnce({storefrontCrawlerSignatures: null}) + + await expect(fetchOrCreateCrawlerSignatureHeaders(adminSession)).resolves.toBeUndefined() + expect(outputDebug).toHaveBeenCalledWith( + expect.stringContaining('Could not obtain crawler signature headers; continuing without them.'), + ) + }) + + test('continues without crawler signature headers when generation returns user errors', async () => { + vi.mocked(adminRequest) + .mockResolvedValueOnce(emptyCrawlerSignaturesResponse) + .mockResolvedValueOnce({ + storefrontCrawlerSignatureGenerate: { + ...crawlerSignature, + userErrors: [ + { + field: ['domainHost'], + message: 'Domain is not connected', + code: 'INVALID', + }, + ], + }, + }) + + await expect(fetchOrCreateCrawlerSignatureHeaders(adminSession)).resolves.toBeUndefined() + expect(outputDebug).toHaveBeenCalledWith( + expect.stringContaining('Could not obtain crawler signature headers; continuing without them.'), + ) + }) + + test('continues without crawler signature headers when generation does not return a signature', async () => { + vi.mocked(adminRequest).mockResolvedValueOnce(emptyCrawlerSignaturesResponse).mockResolvedValueOnce({ + storefrontCrawlerSignatureGenerate: null, + }) + + await expect(fetchOrCreateCrawlerSignatureHeaders(adminSession)).resolves.toBeUndefined() + expect(outputDebug).toHaveBeenCalledWith( + expect.stringContaining('Could not obtain crawler signature headers; continuing without them.'), + ) + }) + + test('continues without crawler signature headers when generation omits a required header', async () => { + vi.mocked(adminRequest) + .mockResolvedValueOnce(emptyCrawlerSignaturesResponse) + .mockResolvedValueOnce({ + storefrontCrawlerSignatureGenerate: { + ...crawlerSignature, + signatureInput: '', + userErrors: [], + }, + }) + + await expect(fetchOrCreateCrawlerSignatureHeaders(adminSession)).resolves.toBeUndefined() + expect(outputDebug).toHaveBeenCalledWith( + expect.stringContaining('Could not obtain crawler signature headers; continuing without them.'), + ) + }) +}) diff --git a/packages/theme/src/cli/utilities/theme-environment/crawler-signature.ts b/packages/theme/src/cli/utilities/theme-environment/crawler-signature.ts new file mode 100644 index 00000000000..551a6e7ae8e --- /dev/null +++ b/packages/theme/src/cli/utilities/theme-environment/crawler-signature.ts @@ -0,0 +1,220 @@ +import {adminRequest} from '@shopify/cli-kit/node/api/admin' +import {AbortError} from '@shopify/cli-kit/node/error' +import {outputDebug} from '@shopify/cli-kit/node/output' +import {type AdminSession} from '@shopify/cli-kit/node/session' +import {recordError, recordEvent} from '@shopify/cli-kit/node/analytics' + +export interface CrawlerSignatureHeaders { + Signature: string + 'Signature-Input': string + 'Signature-Agent': string +} + +interface StorefrontCrawlerSignature { + id: string + name: string + domainHost: string + signature: string + signatureInput: string + signatureAgent: string + expiresAt: string +} + +interface StorefrontCrawlerSignatureUserError { + field?: string[] | null + message: string + code?: string | null +} + +interface StorefrontCrawlerSignaturesResponse { + storefrontCrawlerSignatures?: { + edges: { + node: StorefrontCrawlerSignature + }[] + } | null +} + +interface StorefrontCrawlerSignatureGenerateResponse { + storefrontCrawlerSignatureGenerate?: + | (StorefrontCrawlerSignature & { + userErrors: StorefrontCrawlerSignatureUserError[] + }) + | null +} + +export const CRAWLER_SIGNATURE_NAME = 'Shopify CLI' +export const CRAWLER_SIGNATURE_TTL_SECONDS = 30 * 24 * 60 * 60 + +const CRAWLER_SIGNATURE_HEADER_NAMES = ['Signature', 'Signature-Input', 'Signature-Agent'] as const +const ADMIN_API_VERSION = 'unstable' + +const STOREFRONT_CRAWLER_SIGNATURES_QUERY = ` + query StorefrontCrawlerSignatures($first: Int!, $expired: Boolean, $cli: Boolean, $domain: String) { + storefrontCrawlerSignatures(first: $first, expired: $expired, cli: $cli, domain: $domain) { + edges { + node { + id + name + domainHost + signature + signatureInput + signatureAgent + expiresAt + } + } + } + } +` + +// eslint-disable-next-line @shopify/cli/no-inline-graphql +const STOREFRONT_CRAWLER_SIGNATURE_GENERATE_MUTATION = ` + mutation StorefrontCrawlerSignatureGenerate($timeToLive: Int!, $name: String!, $domainHost: String!, $cli: Boolean) { + storefrontCrawlerSignatureGenerate(timeToLive: $timeToLive, name: $name, domainHost: $domainHost, cli: $cli) { + id + signature + signatureInput + signatureAgent + name + expiresAt + domainHost + userErrors { + field + message + code + } + } + } +` + +export async function fetchOrCreateCrawlerSignatureHeaders( + adminSession: AdminSession, + domainHost = adminSession.storeFqdn, +): Promise { + try { + return await fetchOrCreateCrawlerSignatureHeadersOrThrow(adminSession, domainHost) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch (error) { + outputDebug(`Could not obtain crawler signature headers; continuing without them. ${errorMessage(error)}`) + return undefined + } +} + +async function fetchOrCreateCrawlerSignatureHeadersOrThrow( + adminSession: AdminSession, + domainHost: string, +): Promise { + const existingSignature = await findReusableCrawlerSignature(adminSession, domainHost) + + if (existingSignature) { + recordEvent('theme-service:crawler-signature:reused') + const headers = crawlerSignatureHeadersFromSignature(existingSignature) + outputDebug( + `Reusing crawler signature "${CRAWLER_SIGNATURE_NAME}" for ${existingSignature.domainHost} (${crawlerSignatureHeaderDebugSummary( + headers, + )}).`, + ) + return headers + } + + const createdSignature = await createCrawlerSignature(adminSession, domainHost) + recordEvent('theme-service:crawler-signature:created') + const headers = crawlerSignatureHeadersFromSignature(createdSignature) + outputDebug( + `Created crawler signature "${CRAWLER_SIGNATURE_NAME}" for ${createdSignature.domainHost} expiring at ${createdSignature.expiresAt} (${crawlerSignatureHeaderDebugSummary( + headers, + )}).`, + ) + + return headers +} + +export function crawlerSignatureHeaderDebugSummary(headers: object): string { + const headerNames = Object.keys(headers) + const present = CRAWLER_SIGNATURE_HEADER_NAMES.filter((crawlerHeaderName) => + headerNames.some((headerName) => headerName.toLowerCase() === crawlerHeaderName.toLowerCase()), + ) + const missing = CRAWLER_SIGNATURE_HEADER_NAMES.filter((crawlerHeaderName) => !present.includes(crawlerHeaderName)) + + return `present: ${present.join(', ') || 'none'}; missing: ${missing.join(', ') || 'none'}` +} + +async function findReusableCrawlerSignature( + adminSession: AdminSession, + domainHost: string, +): Promise { + const response = await adminRequest( + STOREFRONT_CRAWLER_SIGNATURES_QUERY, + adminSession, + { + first: 1, + expired: false, + cli: true, + domain: domainHost, + }, + ADMIN_API_VERSION, + ) + + const connection = response.storefrontCrawlerSignatures + if (!connection) { + throw recordError(new AbortError('Could not fetch Shopify CLI crawler signatures.')) + } + + return connection.edges[0]?.node +} + +async function createCrawlerSignature( + adminSession: AdminSession, + domainHost: string, +): Promise { + const response = await adminRequest( + STOREFRONT_CRAWLER_SIGNATURE_GENERATE_MUTATION, + adminSession, + { + timeToLive: CRAWLER_SIGNATURE_TTL_SECONDS, + name: CRAWLER_SIGNATURE_NAME, + domainHost, + cli: true, + }, + ADMIN_API_VERSION, + ) + + const signature = response.storefrontCrawlerSignatureGenerate + if (!signature) { + throw recordError(new AbortError('Could not create a Shopify CLI crawler signature.')) + } + + if (signature.userErrors.length > 0) { + throw recordError( + new AbortError('Could not create a Shopify CLI crawler signature.', formatUserErrors(signature.userErrors)), + ) + } + + return signature +} + +function crawlerSignatureHeadersFromSignature(signature: StorefrontCrawlerSignature): CrawlerSignatureHeaders { + if (!signature.signature || !signature.signatureInput || !signature.signatureAgent) { + throw recordError( + new AbortError('The Shopify CLI crawler signature response did not include all required headers.'), + ) + } + + return { + Signature: signature.signature, + 'Signature-Input': signature.signatureInput, + 'Signature-Agent': signature.signatureAgent, + } +} + +function formatUserErrors(userErrors: StorefrontCrawlerSignatureUserError[]) { + return userErrors + .map((error) => { + const field = error.field?.join('.') + return field ? `${field}: ${error.message}` : error.message + }) + .join('\n') +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} From dc8eec07a57f098f970f9b4e5c5b3c670fd7ea83 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 11 Jun 2026 14:00:01 -0700 Subject: [PATCH 4/5] Send crawler signatures with theme dev storefront requests Thread CLI crawler signature headers through the theme dev session so render requests, proxied storefront requests, session-cookie requests, and password checks all reach Storefront Renderer as verified Shopify crawler traffic. --- packages/theme/src/cli/services/dev.test.ts | 17 ++++ packages/theme/src/cli/services/dev.ts | 15 ++-- .../dev-server-session.test.ts | 85 +++++++++++++++++++ .../theme-environment/dev-server-session.ts | 45 +++++++--- .../utilities/theme-environment/proxy.test.ts | 35 ++++++++ .../cli/utilities/theme-environment/proxy.ts | 1 + .../storefront-password-prompt.test.ts | 14 +++ .../storefront-password-prompt.ts | 18 +++- .../storefront-renderer.test.ts | 27 ++++++ .../theme-environment/storefront-renderer.ts | 16 ++-- .../storefront-session.test.ts | 82 ++++++++++++++++++ .../theme-environment/storefront-session.ts | 40 ++++++--- .../cli/utilities/theme-environment/types.ts | 8 ++ 13 files changed, 363 insertions(+), 40 deletions(-) diff --git a/packages/theme/src/cli/services/dev.test.ts b/packages/theme/src/cli/services/dev.test.ts index a0ca32ca763..7a16294608d 100644 --- a/packages/theme/src/cli/services/dev.test.ts +++ b/packages/theme/src/cli/services/dev.test.ts @@ -3,6 +3,7 @@ import {setupDevServer} from '../utilities/theme-environment/theme-environment.j import {hasRequiredThemeDirectories} from '../utilities/theme-fs.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' +import {fetchOrCreateCrawlerSignatureHeaders} from '../utilities/theme-environment/crawler-signature.js' import {buildTheme} from '@shopify/cli-kit/node/themes/factories' import {describe, expect, test, vi, beforeEach, afterEach, type MockInstance} from 'vitest' import {DEVELOPMENT_THEME_ROLE} from '@shopify/cli-kit/node/themes/utils' @@ -26,6 +27,8 @@ vi.mock('@shopify/cli-kit/node/system', () => ({ })) vi.mock('@shopify/cli-kit/node/analytics', () => ({ reportAnalyticsEvent: vi.fn(), + recordEvent: vi.fn(), + recordError: vi.fn((error) => error), })) vi.mock('@shopify/cli-kit/node/metadata', () => ({ addPublicMetadata: vi.fn(), @@ -51,9 +54,21 @@ vi.mock('../utilities/theme-environment/storefront-session.js', () => ({ vi.mock('../utilities/theme-environment/dev-server-session.js', () => ({ initializeDevServerSession: vi.fn(), })) +vi.mock('../utilities/theme-environment/crawler-signature.js', async (realImport) => { + const realModule = await realImport() + return { + ...realModule, + fetchOrCreateCrawlerSignatureHeaders: vi.fn(), + } +}) const store = 'my-store.myshopify.com' const theme = buildTheme({id: 123, name: 'My Theme', role: DEVELOPMENT_THEME_ROLE})! +const crawlerSignatureHeaders = { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', +} describe('renderLinks', () => { test('renders "dev" command links', async () => { @@ -296,6 +311,7 @@ describe('dev() Ctrl-C analytics', () => { beforeEach(() => { vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockResolvedValue(crawlerSignatureHeaders) vi.mocked(initializeDevServerSession).mockResolvedValue({ storeFqdn: adminSession.storeFqdn, token: adminSession.token, @@ -419,6 +435,7 @@ describe('dev() port validation', () => { beforeEach(() => { vi.mocked(hasRequiredThemeDirectories).mockResolvedValue(true) vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(false) + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockResolvedValue(crawlerSignatureHeaders) vi.mocked(initializeDevServerSession).mockResolvedValue({ storeFqdn: adminSession.storeFqdn, token: adminSession.token, diff --git a/packages/theme/src/cli/services/dev.ts b/packages/theme/src/cli/services/dev.ts index f9f440bcc69..5c657aaabbe 100644 --- a/packages/theme/src/cli/services/dev.ts +++ b/packages/theme/src/cli/services/dev.ts @@ -1,11 +1,12 @@ import {hasRequiredThemeDirectories, mountThemeFileSystem} from '../utilities/theme-fs.js' import {ensureDirectoryConfirmed} from '../utilities/theme-ui.js' import {setupDevServer} from '../utilities/theme-environment/theme-environment.js' -import {DevServerContext, ErrorOverlayMode, LiveReload} from '../utilities/theme-environment/types.js' +import {type DevServerContext, type ErrorOverlayMode, type LiveReload} from '../utilities/theme-environment/types.js' import {isStorefrontPasswordProtected} from '../utilities/theme-environment/storefront-session.js' import {ensureValidPassword} from '../utilities/theme-environment/storefront-password-prompt.js' import {emptyThemeExtFileSystem} from '../utilities/theme-fs-empty.js' import {initializeDevServerSession} from '../utilities/theme-environment/dev-server-session.js' +import {fetchOrCreateCrawlerSignatureHeaders} from '../utilities/theme-environment/crawler-signature.js' import {ensureListingExists} from '../utilities/theme-listing.js' import {renderSuccess, renderWarning} from '@shopify/cli-kit/node/ui' import {AdminSession} from '@shopify/cli-kit/node/session' @@ -81,9 +82,13 @@ export async function dev(options: DevOptions) { await ensureListingExists(options.directory, options.listing) } - const storefrontPasswordPromise = await isStorefrontPasswordProtected(options.adminSession).then((needsPassword) => - needsPassword ? ensureValidPassword(options.storePassword, options.adminSession.storeFqdn) : undefined, - ) + const [crawlerSignatureHeaders, isPasswordProtected] = await Promise.all([ + fetchOrCreateCrawlerSignatureHeaders(options.adminSession), + isStorefrontPasswordProtected(options.adminSession), + ]) + const storefrontPassword = isPasswordProtected + ? await ensureValidPassword(options.storePassword, options.adminSession.storeFqdn, crawlerSignatureHeaders) + : undefined const localThemeExtensionFileSystem = emptyThemeExtFileSystem() const localThemeFileSystem = mountThemeFileSystem(options.directory, { @@ -113,12 +118,12 @@ export async function dev(options: DevOptions) { preview: `https://${options.store}/?preview_theme_id=${options.theme.id}`, } - const storefrontPassword = await storefrontPasswordPromise const session = await initializeDevServerSession( options.theme.id.toString(), options.adminSession, options.password, storefrontPassword, + crawlerSignatureHeaders, ) const ctx: DevServerContext = { session, diff --git a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts index d9f28dc5961..17d8d65417a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.test.ts @@ -1,3 +1,4 @@ +import {fetchOrCreateCrawlerSignatureHeaders} from './crawler-signature.js' import {getStorefrontSessionCookies, ShopifyEssentialError} from './storefront-session.js' import { abortOnMissingRequiredFile, @@ -13,6 +14,13 @@ import {outputContent, outputToken} from '@shopify/cli-kit/node/output' vi.mock('@shopify/cli-kit/node/session') vi.mock('@shopify/cli-kit/node/themes/api') +vi.mock('./crawler-signature.js', async (realImport) => { + const realModule = await realImport() + return { + ...realModule, + fetchOrCreateCrawlerSignatureHeaders: vi.fn(), + } +}) vi.mock('./storefront-session.js') const storeFqdn = 'my-shop.myshopify.com' @@ -31,6 +39,16 @@ const mockConfigAsset = { value: '[]', checksum: 'fdsa', } +const crawlerSignatureHeaders = { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', +} + +beforeEach(() => { + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockReset() + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockResolvedValue(crawlerSignatureHeaders) +}) describe('getStorefrontSessionCookiesWithVerification', () => { test('calls verifyRequiredFilesExist when ShopifyEssentialError is thrown', async () => { @@ -109,6 +127,72 @@ describe('dev server session', async () => { noPrompt: true, }) }) + + test('fetches crawler signature headers when creating storefront session cookies', async () => { + vi.mocked(ensureAuthenticatedStorefront).mockResolvedValue('storefront_token') + vi.mocked(getStorefrontSessionCookies).mockResolvedValue({ + _shopify_essential: ':AABBCCDDEEFFGGHH==123:', + }) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({ + token: 'token_1', + storeFqdn, + }) + + await fetchDevServerSession(themeId, adminSession, 'admin-password') + + expect(fetchOrCreateCrawlerSignatureHeaders).toHaveBeenCalledWith(adminSession) + expect(getStorefrontSessionCookies).toHaveBeenCalledWith( + 'https://my-shop.myshopify.com', + storeFqdn, + themeId, + undefined, + expect.objectContaining(crawlerSignatureHeaders), + ) + }) + + test('uses caller-provided crawler signature headers when creating storefront session cookies', async () => { + vi.mocked(ensureAuthenticatedStorefront).mockResolvedValue('storefront_token') + vi.mocked(getStorefrontSessionCookies).mockResolvedValue({ + _shopify_essential: ':AABBCCDDEEFFGGHH==123:', + }) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({ + token: 'token_1', + storeFqdn, + }) + + await fetchDevServerSession(themeId, adminSession, 'admin-password', undefined, crawlerSignatureHeaders) + + expect(fetchOrCreateCrawlerSignatureHeaders).not.toHaveBeenCalled() + expect(getStorefrontSessionCookies).toHaveBeenCalledWith( + 'https://my-shop.myshopify.com', + storeFqdn, + themeId, + undefined, + expect.objectContaining(crawlerSignatureHeaders), + ) + }) + + test('continues without crawler signature headers when none can be fetched', async () => { + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockResolvedValue(undefined) + vi.mocked(ensureAuthenticatedStorefront).mockResolvedValue('storefront_token') + vi.mocked(getStorefrontSessionCookies).mockResolvedValue({ + _shopify_essential: ':AABBCCDDEEFFGGHH==123:', + }) + vi.mocked(ensureAuthenticatedThemes).mockResolvedValue({ + token: 'token_1', + storeFqdn, + }) + + await fetchDevServerSession(themeId, adminSession, 'admin-password') + + expect(getStorefrontSessionCookies).toHaveBeenCalledWith( + 'https://my-shop.myshopify.com', + storeFqdn, + themeId, + undefined, + expect.not.objectContaining(crawlerSignatureHeaders), + ) + }) }) describe('initializeDevServerSession', async () => { @@ -174,6 +258,7 @@ describe('dev server session', async () => { token: 'token_3', }), ) + expect(fetchOrCreateCrawlerSignatureHeaders).toHaveBeenCalledOnce() }) }) }) diff --git a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts index 7e2192f3a39..e60705a24df 100644 --- a/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts +++ b/packages/theme/src/cli/utilities/theme-environment/dev-server-session.ts @@ -1,4 +1,5 @@ -import {DevServerSession} from './types.js' +import {type CrawlerSignatureHeaders, type DevServerSession} from './types.js' +import {fetchOrCreateCrawlerSignatureHeaders} from './crawler-signature.js' import {getStorefrontSessionCookies, ShopifyEssentialError} from './storefront-session.js' import {buildBaseStorefrontUrl} from './storefront-renderer.js' import {fetchThemeAssets} from '@shopify/cli-kit/node/themes/api' @@ -15,10 +16,11 @@ const REQUIRED_THEME_FILES = ['layout/theme.liquid', 'config/settings_schema.jso * Initialize the session object, which is automatically refreshed * every 30 minutes. * - * @param themeId - The theme being rendered in this session. - * @param adminSession - Admin session with the initial access token and store. - * @param adminPassword - Custom app password or password generated by the Theme Access app. - * @param storefrontPassword - Storefront password set in password-protected stores. + * @param themeId - The theme being rendered in this session. + * @param adminSession - Admin session with the initial access token and store. + * @param adminPassword - Custom app password or password generated by the Theme Access app. + * @param storefrontPassword - Storefront password set in password-protected stores. + * @param crawlerSignatureHeaders - Crawler signature headers sent with storefront requests. * * @returns Details about the app configuration state. */ @@ -27,12 +29,25 @@ export async function initializeDevServerSession( adminSession: AdminSession, adminPassword?: string, storefrontPassword?: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, ) { - const session = await fetchDevServerSession(themeId, adminSession, adminPassword, storefrontPassword) + const session = await fetchDevServerSession( + themeId, + adminSession, + adminPassword, + storefrontPassword, + crawlerSignatureHeaders, + ) session.refresh = async () => { outputDebug('Refreshing theme session...') - const newSession = await fetchDevServerSession(themeId, adminSession, adminPassword, storefrontPassword) + const newSession = await fetchDevServerSession( + themeId, + adminSession, + adminPassword, + storefrontPassword, + session.crawlerSignatureHeaders, + ) recordEvent('theme-service:session:refreshed') Object.assign(session, newSession) return newSession @@ -53,10 +68,11 @@ export async function initializeDevServerSession( /** * Initialize the session object, without automatic refresh. * - * @param themeId - The theme being rendered in this session. - * @param adminSession - Admin session with the initial access token and store. - * @param adminPassword - Custom app password or password generated by the Theme Access app. - * @param storefrontPassword - Storefront password set in password-protected stores. + * @param themeId - The theme being rendered in this session. + * @param adminSession - Admin session with the initial access token and store. + * @param adminPassword - Custom app password or password generated by the Theme Access app. + * @param storefrontPassword - Storefront password set in password-protected stores. + * @param crawlerSignatureHeaders - Crawler signature headers sent with storefront requests. * * @returns Details about the app configuration state. */ @@ -65,8 +81,11 @@ export async function fetchDevServerSession( adminSession: AdminSession, adminPassword?: string, storefrontPassword?: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, ): Promise { const baseUrl = buildBaseStorefrontUrl(adminSession) + const sessionCrawlerSignatureHeaders = + crawlerSignatureHeaders ?? (await fetchOrCreateCrawlerSignatureHeaders(adminSession)) const session = await ensureAuthenticatedThemes(adminSession.storeFqdn, adminPassword, [], { forceRefresh: false, @@ -79,12 +98,14 @@ export async function fetchDevServerSession( adminSession, storefrontToken, storefrontPassword, + sessionCrawlerSignatureHeaders, ) return { ...session, sessionCookies, storefrontToken, + crawlerSignatureHeaders: sessionCrawlerSignatureHeaders, } } @@ -94,12 +115,14 @@ export async function getStorefrontSessionCookiesWithVerification( adminSession: AdminSession, storefrontToken: string, storefrontPassword?: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, ): Promise> { try { return await getStorefrontSessionCookies(storeUrl, adminSession.storeFqdn, themeId, storefrontPassword, { 'X-Shopify-Shop': adminSession.storeFqdn, 'X-Shopify-Access-Token': adminSession.token, Authorization: `Bearer ${storefrontToken}`, + ...crawlerSignatureHeaders, }) } catch (error) { if (error instanceof ShopifyEssentialError) { diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts index def95497c22..3dbf934b9ee 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.test.ts @@ -516,6 +516,41 @@ describe('dev proxy', () => { 'Request failed: Hostname mismatch. Expected host: cdn.shopify.com. Resulting URL hostname: evil.com', ) }) + + test('passes crawler signature headers to proxied SFR requests', async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response('OK')) + vi.stubGlobal('fetch', fetchMock) + const event = createH3Event('GET', '/cart.js') + const localCtx = { + ...ctx, + type: 'theme', + session: { + storeFqdn: 'my-store.myshopify.com', + sessionCookies: {}, + storefrontToken: 'sfr-devtools-token', + crawlerSignatureHeaders: { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }, + }, + } as unknown as DevServerContext + + try { + await proxyStorefrontRequest(event, localCtx) + + const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit] + expect(init.headers).toEqual( + expect.objectContaining({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }), + ) + } finally { + vi.unstubAllGlobals() + } + }) }) describe('proxyStorefrontRequest — Storefront API passthrough', () => { diff --git a/packages/theme/src/cli/utilities/theme-environment/proxy.ts b/packages/theme/src/cli/utilities/theme-environment/proxy.ts index 1a59801faa2..deaa82d9b34 100644 --- a/packages/theme/src/cli/utilities/theme-environment/proxy.ts +++ b/packages/theme/src/cli/utilities/theme-environment/proxy.ts @@ -341,6 +341,7 @@ export function proxyStorefrontRequest(event: H3Event, ctx: DevServerContext): P headers = cleanHeader({ ...headers, ...defaultHeaders(), + ...(host === ctx.session.storeFqdn ? ctx.session.crawlerSignatureHeaders : {}), referer: url.origin, Cookie: buildCookies(ctx.session, {headers}), // Only include Authorization for theme dev, not theme-extensions diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.test.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.test.ts index 952d5b2c1ba..a50f1d5878a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.test.ts @@ -72,6 +72,20 @@ describe('ensureValidPassword', () => { expect(isStorefrontPasswordCorrect).toHaveBeenCalledWith('testPassword', 'test-store') }) + test('passes crawler signature headers when validating the password', async () => { + vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) + vi.mocked(isStorefrontPasswordCorrect).mockResolvedValue(true) + const crawlerSignatureHeaders = { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + } + + await ensureValidPassword('testPassword', 'test-store', crawlerSignatureHeaders) + + expect(isStorefrontPasswordCorrect).toHaveBeenCalledWith('testPassword', 'test-store', crawlerSignatureHeaders) + }) + test('should set the password in local storage when a password is validated', async () => { // Given vi.mocked(isStorefrontPasswordProtected).mockResolvedValue(true) diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts index aef7bd773e2..15777753e5a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-password-prompt.ts @@ -1,3 +1,4 @@ +import {type CrawlerSignatureHeaders} from './crawler-signature.js' import {isStorefrontPasswordCorrect} from './storefront-session.js' import { getStorefrontPassword, @@ -10,7 +11,11 @@ import {renderTextPrompt, TokenItem} from '@shopify/cli-kit/node/ui' import {storePasswordPage} from '@shopify/cli-kit/node/themes/urls' -export async function ensureValidPassword(password: string | undefined, store: string) { +export async function ensureValidPassword( + password: string | undefined, + store: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, +) { /* * This allows us to call ensureValidPassword() in other packages * without the need to explicitly import and call ensureThemeStore() upstream @@ -34,7 +39,7 @@ export async function ensureValidPassword(password: string | undefined, store: s let isPasswordRemoved = false // eslint-disable-next-line no-await-in-loop - while (!(await isStorefrontPasswordCorrect(finalPassword, store))) { + while (!(await passwordIsCorrect(finalPassword, store, crawlerSignatureHeaders))) { if (!isPasswordRemoved) { removeStorefrontPassword() isPasswordRemoved = true @@ -59,6 +64,15 @@ export async function ensureValidPassword(password: string | undefined, store: s return finalPassword } +function passwordIsCorrect( + password: string | undefined, + store: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, +) { + if (crawlerSignatureHeaders) return isStorefrontPasswordCorrect(password, store, crawlerSignatureHeaders) + return isStorefrontPasswordCorrect(password, store) +} + async function promptPassword(prompt: TokenItem): Promise { return renderTextPrompt({ message: prompt, diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts index 3b75a96391c..ab1a1ab048a 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.test.ts @@ -59,6 +59,33 @@ describe('render', () => { ) }) + test('passes crawler signature headers to SFR requests', async () => { + vi.mocked(fetch).mockResolvedValue(new Response(null, {headers: {'Content-Type': 'application/json'}})) + + await render( + { + ...session, + crawlerSignatureHeaders: { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }, + }, + context, + ) + + expect(fetch).toHaveBeenCalledWith( + 'https://store.myshopify.com/products/1?_fd=0&pb=0', + expect.objectContaining({ + headers: expect.objectContaining({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }), + }), + ) + }) + test('preserves Content-Type header for JSON responses', async () => { // Given vi.mocked(fetch).mockResolvedValue( diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts index efd4d654402..adf7b47fc6e 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-renderer.ts @@ -10,6 +10,10 @@ import {recordError} from '@shopify/cli-kit/node/analytics' export async function render(session: DevServerSession, context: DevServerRenderContext): Promise { const url = buildStorefrontUrl(session, context) const headers = await buildHeaders(session, context) + const requestHeaders = { + ...headers, + ...defaultHeaders(), + } let response: Response const replaceTemplates = Object.keys({...context.replaceTemplates, ...context.replaceExtensionTemplates}) @@ -24,10 +28,7 @@ export async function render(session: DevServerSession, context: DevServerRender method: 'POST', body: bodyParams, redirect: 'manual', - headers: { - ...headers, - ...defaultHeaders(), - }, + headers: requestHeaders, }).catch((error) => { throw createFetchError(recordError(error), url) }) @@ -38,10 +39,7 @@ export async function render(session: DevServerSession, context: DevServerRender response = await fetch(url, { method: context.method, redirect: 'manual', - headers: { - ...headers, - ...defaultHeaders(), - }, + headers: requestHeaders, }).catch((error) => { throw createFetchError(recordError(error), url) }) @@ -81,6 +79,7 @@ async function buildStandardHeaders(session: DevServerSession, context: Pick { expect(cookies).toEqual({_shopify_essential: ':AABBCCDDEEFFGGHH==123:', storefront_digest: 'digest-value'}) }) + test('passes caller-provided headers when retrieving session cookies', async () => { + vi.mocked(shopifyFetch) + .mockResolvedValueOnce( + response({ + status: 200, + headers: {'set-cookie': '_shopify_essential=:AABBCCDDEEFFGGHH==123:; path=/; HttpOnly'}, + }), + ) + .mockResolvedValueOnce( + response({ + status: 302, + headers: { + 'set-cookie': 'storefront_digest=digest-value; path=/; HttpOnly', + location: 'https://example-store.myshopify.com/', + }, + }), + ) + + await getStorefrontSessionCookies( + 'https://example-store.myshopify.com', + 'example-store.myshopify.com', + '123456', + 'password', + { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }, + ) + + expect(shopifyFetch).toHaveBeenNthCalledWith( + 1, + 'https://example-store.myshopify.com?preview_theme_id=123456&_fd=0&pb=0', + expect.objectContaining({ + headers: expect.objectContaining({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }), + }), + ) + expect(shopifyFetch).toHaveBeenNthCalledWith( + 2, + 'https://example-store.myshopify.com/password', + expect.objectContaining({ + headers: expect.objectContaining({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }), + }), + ) + }) + test(`throws an ShopifyEssentialError when _shopify_essential can't be defined`, async () => { // Given vi.mocked(shopifyFetch) @@ -432,6 +486,34 @@ describe('Storefront API', () => { }) }) + test('passes crawler signature headers when checking the storefront password', async () => { + vi.mocked(shopifyFetch).mockResolvedValueOnce( + response({ + status: 302, + headers: { + location: 'https://store.myshopify.com/', + }, + }), + ) + + await isStorefrontPasswordCorrect('correct-password', 'store.myshopify.com', { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }) + + expect(shopifyFetch).toBeCalledWith( + 'https://store.myshopify.com/password', + expect.objectContaining({ + headers: expect.objectContaining({ + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + }), + }), + ) + }) + test('returns false when the password is incorrect', async () => { // Given vi.mocked(shopifyFetch).mockResolvedValueOnce( diff --git a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts index e477b1b2ad8..e6030f0d881 100644 --- a/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts +++ b/packages/theme/src/cli/utilities/theme-environment/storefront-session.ts @@ -1,5 +1,6 @@ import {defaultHeaders} from './storefront-utils.js' import {parseCookies, serializeCookies} from './cookies.js' +import {type CrawlerSignatureHeaders} from './crawler-signature.js' import {shopifyFetch, Response} from '@shopify/cli-kit/node/http' import {AbortError} from '@shopify/cli-kit/node/error' import {outputDebug} from '@shopify/cli-kit/node/output' @@ -18,7 +19,11 @@ export async function isStorefrontPasswordProtected(session: AdminSession): Prom * Sends a request to the password redirect page. * If the password is correct, SFR will respond with a 302 to redirect to the storefront */ -export async function isStorefrontPasswordCorrect(password: string | undefined, store: string) { +export async function isStorefrontPasswordCorrect( + password: string | undefined, + store: string, + crawlerSignatureHeaders?: CrawlerSignatureHeaders, +) { const storeUrl = prependHttps(store) const params = new URLSearchParams() @@ -28,11 +33,14 @@ export async function isStorefrontPasswordCorrect(password: string | undefined, recordEvent('theme-service:storefront-session:check-storefront-password') + const requestHeaders = { + 'cache-control': 'no-cache', + 'content-type': 'application/x-www-form-urlencoded', + ...crawlerSignatureHeaders, + } + const response = await shopifyFetch(`${storeUrl}/password`, { - headers: { - 'cache-control': 'no-cache', - 'content-type': 'application/x-www-form-urlencoded', - }, + headers: requestHeaders, body: params.toString(), method: 'POST', redirect: 'manual', @@ -94,13 +102,15 @@ async function sessionEssentialCookie(storeUrl: string, themeId: string, headers recordEvent(`theme-service:storefront-session:get-session-essential-cookie`) + const requestHeaders = { + ...headers, + ...defaultHeaders(), + } + const response = await shopifyFetch(url, { method: 'HEAD', redirect: 'manual', - headers: { - ...headers, - ...defaultHeaders(), - }, + headers: requestHeaders, }) const setCookies = response.headers.raw()['set-cookie'] ?? [] @@ -144,15 +154,17 @@ async function enrichSessionWithStorefrontPassword( ): Promise> { const params = new URLSearchParams({password}) + const requestHeaders = { + ...headers, + ...defaultHeaders(), + Cookie: serializeCookies({_shopify_essential: shopifyEssential}), + } + const response = await shopifyFetch(`${storeUrl}/password`, { method: 'POST', redirect: 'manual', body: params, - headers: { - ...headers, - ...defaultHeaders(), - Cookie: serializeCookies({_shopify_essential: shopifyEssential}), - }, + headers: requestHeaders, }) if (!redirectsToStorefront(response, storeOrigin)) { diff --git a/packages/theme/src/cli/utilities/theme-environment/types.ts b/packages/theme/src/cli/utilities/theme-environment/types.ts index b019515d95a..6a58821a6ac 100644 --- a/packages/theme/src/cli/utilities/theme-environment/types.ts +++ b/packages/theme/src/cli/utilities/theme-environment/types.ts @@ -1,6 +1,9 @@ +import {type CrawlerSignatureHeaders} from './crawler-signature.js' import {AdminSession} from '@shopify/cli-kit/node/session' import {ThemeExtensionFileSystem, ThemeFileSystem} from '@shopify/cli-kit/node/themes/types' +export type {CrawlerSignatureHeaders} from './crawler-signature.js' + /** * Defines an authentication session for the theme development server. * @@ -22,6 +25,11 @@ export interface DevServerSession extends AdminSession { */ storefrontPassword?: string + /** + * Crawler signature headers used to authenticate storefront rendering requests. + */ + crawlerSignatureHeaders?: CrawlerSignatureHeaders + /** * This holds all cookies that impact the rendering of the development server. * From d4b78c19e4bacf86eee40ddf1932bf13cccba0e8 Mon Sep 17 00:00:00 2001 From: Gray Gilmore Date: Thu, 11 Jun 2026 14:00:08 -0700 Subject: [PATCH 5/5] Send crawler signatures with theme app extension previews Use the same Admin-backed crawler signature helper in app dev theme extension previews and pass the resulting headers into the shared theme dev server session. --- .changeset/theme-dev-crawler-signatures.md | 7 +++++++ .../dev/processes/theme-app-extension.test.ts | 12 +++++++++-- .../dev/processes/theme-app-extension.ts | 21 +++++++++++++++---- .../theme-ext-environment/theme-ext-server.ts | 19 ++++++++++++++--- packages/theme/src/index.ts | 4 ++++ 5 files changed, 54 insertions(+), 9 deletions(-) create mode 100644 .changeset/theme-dev-crawler-signatures.md diff --git a/.changeset/theme-dev-crawler-signatures.md b/.changeset/theme-dev-crawler-signatures.md new file mode 100644 index 00000000000..c1ba134ebd1 --- /dev/null +++ b/.changeset/theme-dev-crawler-signatures.md @@ -0,0 +1,7 @@ +--- +'@shopify/app': patch +'@shopify/cli-kit': patch +'@shopify/theme': patch +--- + +Add crawler signature support to theme development storefront requests. diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts index a2a93321aab..870405d9f4d 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.test.ts @@ -14,7 +14,7 @@ import {Theme} from '@shopify/cli-kit/node/themes/types' import {vi, describe, test, expect, beforeEach} from 'vitest' import {renderInfo} from '@shopify/cli-kit/node/ui' import {partnersFqdn, adminFqdn} from '@shopify/cli-kit/node/context/fqdn' -import {ensureValidPassword, isStorefrontPasswordProtected} from '@shopify/theme' +import {ensureValidPassword, isStorefrontPasswordProtected, fetchOrCreateCrawlerSignatureHeaders} from '@shopify/theme' vi.mock('../../../utilities/extensions/theme/host-theme-manager') vi.mock('@shopify/cli-kit/node/output') @@ -42,14 +42,21 @@ vi.mock('@shopify/theme', async (realImport) => { ...realModule, ensureValidPassword: vi.fn(), isStorefrontPasswordProtected: vi.fn(), + fetchOrCreateCrawlerSignatureHeaders: vi.fn(), } }) describe('setupPreviewThemeAppExtensionsProcess', () => { const mockAdminSession = {storeFqdn: 'test.myshopify.com'} as any as AdminSession + const crawlerSignatureHeaders = { + Signature: 'signature-value', + 'Signature-Input': 'signature-input-value', + 'Signature-Agent': 'signature-agent-value', + } beforeEach(() => { vi.mocked(ensureAuthenticatedAdmin).mockResolvedValue(mockAdminSession) + vi.mocked(fetchOrCreateCrawlerSignatureHeaders).mockResolvedValue(crawlerSignatureHeaders) vi.mocked(partnersFqdn).mockResolvedValue('partners.shopify.com') vi.mocked(adminFqdn).mockResolvedValue('admin.shopify.com') }) @@ -201,8 +208,9 @@ describe('setupPreviewThemeAppExtensionsProcess', () => { }) expect(ensureValidPassword).toHaveBeenCalledOnce() - expect(ensureValidPassword).toHaveBeenCalledWith(undefined, 'test.myshopify.com') + expect(ensureValidPassword).toHaveBeenCalledWith(undefined, 'test.myshopify.com', crawlerSignatureHeaders) expect(result!.options.storefrontPassword).toEqual(storefrontPassword) + expect(result!.options.crawlerSignatureHeaders).toEqual(crawlerSignatureHeaders) }) }) diff --git a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts index cda4751c29c..c9147f3505b 100644 --- a/packages/app/src/cli/services/dev/processes/theme-app-extension.ts +++ b/packages/app/src/cli/services/dev/processes/theme-app-extension.ts @@ -9,13 +9,20 @@ import {fetchTheme} from '@shopify/cli-kit/node/themes/api' import {AbortError} from '@shopify/cli-kit/node/error' import {Theme} from '@shopify/cli-kit/node/themes/types' import {renderInfo, renderTasks, Task} from '@shopify/cli-kit/node/ui' -import {initializeDevelopmentExtensionServer, ensureValidPassword, isStorefrontPasswordProtected} from '@shopify/theme' +import { + initializeDevelopmentExtensionServer, + ensureValidPassword, + isStorefrontPasswordProtected, + fetchOrCreateCrawlerSignatureHeaders, + type CrawlerSignatureHeaders, +} from '@shopify/theme' import {partnersFqdn, adminFqdn} from '@shopify/cli-kit/node/context/fqdn' interface ThemeAppExtensionServerOptions { theme: Theme adminSession: AdminSession storefrontPassword?: string + crawlerSignatureHeaders?: CrawlerSignatureHeaders themeExtensionDirectory: string themeExtensionPort: number } @@ -52,8 +59,12 @@ export async function setupPreviewThemeAppExtensionsProcess( ]) const storeFqdn = adminSession.storeFqdn - const storefrontPassword = (await isStorefrontPasswordProtected(adminSession)) - ? await ensureValidPassword(undefined, storeFqdn) + const [crawlerSignatureHeaders, isPasswordProtected] = await Promise.all([ + fetchOrCreateCrawlerSignatureHeaders(adminSession), + isStorefrontPasswordProtected(adminSession), + ]) + const storefrontPassword = isPasswordProtected + ? await ensureValidPassword(undefined, storeFqdn, crawlerSignatureHeaders) : undefined const theme = await findOrCreateHostTheme(adminSession, options.theme) @@ -99,6 +110,7 @@ export async function setupPreviewThemeAppExtensionsProcess( theme, adminSession, storefrontPassword, + crawlerSignatureHeaders, themeExtensionDirectory, themeExtensionPort, }, @@ -107,11 +119,12 @@ export async function setupPreviewThemeAppExtensionsProcess( export const runThemeAppExtensionsServer: DevProcessFunction = async ( _, - {theme, adminSession, storefrontPassword, themeExtensionDirectory, themeExtensionPort}, + {theme, adminSession, storefrontPassword, crawlerSignatureHeaders, themeExtensionDirectory, themeExtensionPort}, ) => { const server = await initializeDevelopmentExtensionServer(theme, { adminSession, storefrontPassword, + crawlerSignatureHeaders, themeExtensionDirectory, themeExtensionPort, }) diff --git a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts index 43a3482d7eb..3f419f7bb38 100644 --- a/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts +++ b/packages/theme/src/cli/utilities/theme-ext-environment/theme-ext-server.ts @@ -1,5 +1,5 @@ import {mountThemeExtensionFileSystem} from './theme-ext-fs.js' -import {DevServerContext} from '../theme-environment/types.js' +import {type CrawlerSignatureHeaders, DevServerContext} from '../theme-environment/types.js' import {getHtmlHandler} from '../theme-environment/html.js' import {getAssetsHandler} from '../theme-environment/local-assets.js' import {getProxyHandler} from '../theme-environment/proxy.js' @@ -24,6 +24,7 @@ export interface DevExtensionServerContext { themeExtensionPort: number themeExtensionDirectory: string storefrontPassword?: string + crawlerSignatureHeaders?: CrawlerSignatureHeaders } export async function initializeDevelopmentExtensionServer(theme: Theme, devExt: DevExtensionServerContext) { @@ -70,7 +71,13 @@ async function contextDevServerContext( theme: Theme, extensionContext: DevExtensionServerContext, ): Promise { - const {adminSession, storefrontPassword, themeExtensionPort, themeExtensionDirectory: directory} = extensionContext + const { + adminSession, + storefrontPassword, + themeExtensionPort, + themeExtensionDirectory: directory, + crawlerSignatureHeaders, + } = extensionContext const host = '127.0.0.1' const port = themeExtensionPort ?? 9293 @@ -78,7 +85,13 @@ async function contextDevServerContext( const localThemeExtensionFileSystem = mountThemeExtensionFileSystem(directory) await localThemeExtensionFileSystem.ready() - const session = await initializeDevServerSession(theme.id.toString(), adminSession, undefined, storefrontPassword) + const session = await initializeDevServerSession( + theme.id.toString(), + adminSession, + undefined, + storefrontPassword, + crawlerSignatureHeaders, + ) return { session, diff --git a/packages/theme/src/index.ts b/packages/theme/src/index.ts index 31e99bce476..e69636c85e2 100644 --- a/packages/theme/src/index.ts +++ b/packages/theme/src/index.ts @@ -48,6 +48,10 @@ export * from './cli/utilities/theme-ext-environment/theme-ext-server.js' /** Storefront authentication support for running the development server on password-protected stores */ export {isStorefrontPasswordProtected} from './cli/utilities/theme-environment/storefront-session.js' export {ensureValidPassword} from './cli/utilities/theme-environment/storefront-password-prompt.js' +export { + fetchOrCreateCrawlerSignatureHeaders, + type CrawlerSignatureHeaders, +} from './cli/utilities/theme-environment/crawler-signature.js' // Expose core utilities for developers to build and expand on the CLI export {pull} from './cli/services/pull.js'