From 573f91b845cafe9347a47ddc7b8d1a79004d6d37 Mon Sep 17 00:00:00 2001 From: Gonzalo Riestra Date: Thu, 23 Apr 2026 12:56:04 +0200 Subject: [PATCH] refactor(cli-kit): make GraphiQL server auth-agnostic Decouple the GraphiQL HTTP proxy from any specific token strategy or app context so it can be reused outside of `shopify app dev`. - New `TokenProvider` interface with `getToken` and optional `refreshToken`. The proxy delegates auth to the provider, removing the hard-coded `client_credentials` flow. - App-specific concerns (app name/url, app secret) move behind an optional `appContext` option. Without it, the template hides the App pill, swaps the unauthorized badge label, and shows a stored-auth scopes note. - New `protectMutations` option rejects mutation operations server-side with HTTP 400 before forwarding. This lets interactive sessions mirror the safe-by-default semantics of `shopify store execute` without --allow-mutations. - Key resolution: explicit key wins; otherwise derive deterministically from `appContext.apiSecret` + `storeFqdn` (preserving today's behavior for app dev) or fall back to a random per-process key. - Extract `containsMutation(query, operationName?)` to `@shopify/cli-kit/node/graphql` for use by the proxy. - Update the app dev wrapper to provide an in-memory client_credentials `TokenProvider`. Behavior unchanged for `app dev`. Tests cover protectMutations behavior, key handling, token-provider plumbing, and containsMutation across queries, mutations, fragments, named operations, and invalid input. --- .../processes/graphiql-token-provider.test.ts | 113 ++++++++++++ .../dev/processes/graphiql-token-provider.ts | 52 ++++++ .../cli/services/dev/processes/graphiql.ts | 19 +- .../src/public/node/graphiql/server.test.ts | 174 +++++++++++++++++- .../src/public/node/graphiql/server.ts | 154 +++++++++++----- .../node/graphiql/templates/graphiql.tsx | 73 ++++++-- .../node/graphiql/templates/unauthorized.tsx | 103 +++++++---- .../cli-kit/src/public/node/graphql.test.ts | 64 +++++++ packages/cli-kit/src/public/node/graphql.ts | 46 +++++ 9 files changed, 688 insertions(+), 110 deletions(-) create mode 100644 packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts create mode 100644 packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts create mode 100644 packages/cli-kit/src/public/node/graphql.test.ts create mode 100644 packages/cli-kit/src/public/node/graphql.ts diff --git a/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts new file mode 100644 index 00000000000..65f39649788 --- /dev/null +++ b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.test.ts @@ -0,0 +1,113 @@ +import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js' +import {fetch} from '@shopify/cli-kit/node/http' +import {beforeEach, describe, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http') + +const mockedFetch = vi.mocked(fetch) + +function mockTokenResponse(token: string) { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({access_token: token}), + } as unknown as Awaited>) +} + +function mockFailedTokenResponse(status: number, body: object = {}) { + mockedFetch.mockResolvedValueOnce({ + ok: false, + status, + json: async () => body, + } as unknown as Awaited>) +} + +describe('createClientCredentialsTokenProvider', () => { + beforeEach(() => { + mockedFetch.mockReset() + }) + + test('mints a token on first getToken call and caches it', async () => { + mockTokenResponse('first-token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).resolves.toBe('first-token') + await expect(provider.getToken()).resolves.toBe('first-token') + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + + test('refreshToken always re-mints, even when a cached token exists', async () => { + mockTokenResponse('first-token') + mockTokenResponse('second-token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).resolves.toBe('first-token') + await expect(provider.refreshToken!()).resolves.toBe('second-token') + await expect(provider.getToken()).resolves.toBe('second-token') + expect(mockedFetch).toHaveBeenCalledTimes(2) + }) + + test('posts the OAuth client_credentials body to the store admin endpoint', async () => { + mockTokenResponse('token') + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + await provider.getToken() + + expect(mockedFetch).toHaveBeenCalledWith( + 'https://store.myshopify.com/admin/oauth/access_token', + expect.objectContaining({ + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: 'api-key', + client_secret: 'api-secret', + grant_type: 'client_credentials', + }), + }), + ) + }) + + test('throws when the token response is not successful', async () => { + mockFailedTokenResponse(401) + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).rejects.toThrow('Token request failed with status 401') + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) + + test('throws when a successful token response does not include an access token', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({}), + } as unknown as Awaited>) + + const provider = createClientCredentialsTokenProvider({ + apiKey: 'api-key', + apiSecret: 'api-secret', + storeFqdn: 'store.myshopify.com', + }) + + await expect(provider.getToken()).rejects.toThrow('Token request failed with status 200') + expect(mockedFetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts new file mode 100644 index 00000000000..522333693cc --- /dev/null +++ b/packages/app/src/cli/services/dev/processes/graphiql-token-provider.ts @@ -0,0 +1,52 @@ +import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server' +import {fetch} from '@shopify/cli-kit/node/http' + +interface ClientCredentialsTokenProviderOptions { + apiKey: string + apiSecret: string + storeFqdn: string +} + +/** + * Returns a `TokenProvider` that mints Admin API tokens via OAuth `client_credentials` + * using a Partners app's `apiKey` + `apiSecret`. Tokens are cached in-memory and + * re-minted on demand when `refreshToken` is called (e.g. on a 401 from upstream). + * + * This is the strategy used by `shopify app dev`'s GraphiQL server. It assumes the app + * is installed on the target store and that the app secret can mint a fresh token at any time. + */ +export function createClientCredentialsTokenProvider({ + apiKey, + apiSecret, + storeFqdn, +}: ClientCredentialsTokenProviderOptions): TokenProvider { + let cachedToken: string | undefined + + const mint = async (): Promise => { + const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: apiKey, + client_secret: apiSecret, + grant_type: 'client_credentials', + }), + }) + + const tokenJson = (await tokenResponse.json()) as {access_token?: string} + if (!tokenResponse.ok || !tokenJson.access_token) { + throw new Error(`Token request failed with status ${tokenResponse.status}`) + } + + cachedToken = tokenJson.access_token + return cachedToken + } + + return { + getToken: async () => cachedToken ?? mint(), + refreshToken: async () => { + cachedToken = undefined + return mint() + }, + } +} diff --git a/packages/app/src/cli/services/dev/processes/graphiql.ts b/packages/app/src/cli/services/dev/processes/graphiql.ts index 06776b27e78..049741bce0a 100644 --- a/packages/app/src/cli/services/dev/processes/graphiql.ts +++ b/packages/app/src/cli/services/dev/processes/graphiql.ts @@ -1,4 +1,5 @@ import {BaseProcess, DevProcessFunction} from './types.js' +import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js' import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server' interface GraphiQLServerProcessOptions { @@ -30,7 +31,23 @@ export const launchGraphiQLServer: DevProcessFunction { - const httpServer = setupGraphiQLServer({...options, stdout}) + const tokenProvider = createClientCredentialsTokenProvider({ + apiKey: options.apiKey, + apiSecret: options.apiSecret, + storeFqdn: options.storeFqdn, + }) + const httpServer = setupGraphiQLServer({ + stdout, + port: options.port, + storeFqdn: options.storeFqdn, + key: options.key, + tokenProvider, + appContext: { + appName: options.appName, + appUrl: options.appUrl, + apiSecret: options.apiSecret, + }, + }) abortSignal.addEventListener('abort', async () => { httpServer.close() }) diff --git a/packages/cli-kit/src/public/node/graphiql/server.test.ts b/packages/cli-kit/src/public/node/graphiql/server.test.ts index 2a3a90460ae..08c1a76a357 100644 --- a/packages/cli-kit/src/public/node/graphiql/server.test.ts +++ b/packages/cli-kit/src/public/node/graphiql/server.test.ts @@ -1,5 +1,8 @@ -import {deriveGraphiQLKey, resolveGraphiQLKey} from './server.js' -import {describe, expect, test} from 'vitest' +import {deriveGraphiQLKey, resolveGraphiQLKey, setupGraphiQLServer, TokenProvider} from './server.js' +import {getAvailableTCPPort} from '../tcp.js' +import {afterEach, describe, expect, test, vi} from 'vitest' +import {Server} from 'http' +import {Writable} from 'stream' describe('deriveGraphiQLKey', () => { test('returns a 64-character hex string', () => { @@ -47,3 +50,170 @@ describe('resolveGraphiQLKey', () => { expect(key).toBe(deriveGraphiQLKey('secret', 'store.myshopify.com')) }) }) + +describe('setupGraphiQLServer', () => { + const servers: Server[] = [] + + afterEach(() => { + for (const server of servers) server.close() + servers.length = 0 + }) + + /** + * Starts the GraphiQL server with the given options on an available port and + * returns its base URL. Server is auto-closed by the afterEach hook. + */ + async function startServer(options: { + tokenProvider: TokenProvider + storeFqdn?: string + key?: string + protectMutations?: boolean + appContext?: {appName: string; appUrl: string; apiSecret: string} + }) { + const port = await getAvailableTCPPort() + const noopStdout = new Writable({write: (_chunk, _enc, cb) => cb()}) + const server = setupGraphiQLServer({ + stdout: noopStdout, + port, + storeFqdn: options.storeFqdn ?? 'store.myshopify.com', + tokenProvider: options.tokenProvider, + key: options.key, + protectMutations: options.protectMutations, + appContext: options.appContext, + }) + servers.push(server) + await new Promise((resolve) => server.on('listening', () => resolve())) + return {url: `http://localhost:${port}`} + } + + test('rejects mutations with HTTP 400 when protectMutations is true', async () => { + const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')} + const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true}) + + const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}), + }) + + expect(response.status).toBe(400) + const body = (await response.json()) as {errors: {message: string}[]} + expect(body.errors[0]?.message).toMatch(/mutations are disabled/i) + expect(tokenProvider.getToken).not.toHaveBeenCalled() + }) + + test('does not invoke the token provider for blocked mutations', async () => { + const tokenProvider: TokenProvider = { + getToken: vi.fn(async () => 'access-token'), + refreshToken: vi.fn(async () => 'refreshed-token'), + } + const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true}) + + await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'mutation M { shopUpdate(input: {}) { id } }'}), + }) + + expect(tokenProvider.getToken).not.toHaveBeenCalled() + expect(tokenProvider.refreshToken).not.toHaveBeenCalled() + }) + + test('lets queries through to the upstream call when protectMutations is true', async () => { + const tokenProvider: TokenProvider = {getToken: vi.fn(async () => 'access-token')} + const {url} = await startServer({tokenProvider, key: 'k', protectMutations: true}) + + const response = await fetch(`${url}/graphiql/graphql.json?key=k&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'query Q { shop { name } }'}), + }) + + expect(response.status).not.toBe(400) + expect(tokenProvider.getToken).toHaveBeenCalled() + }) + + test('returns 404 when the request key does not match', async () => { + const tokenProvider: TokenProvider = {getToken: async () => 'access-token'} + const {url} = await startServer({tokenProvider, key: 'expected-key'}) + + const response = await fetch(`${url}/graphiql/graphql.json?key=wrong-key&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: '{ shop { name } }'}), + }) + + expect(response.status).toBe(404) + }) + + test('uses the deterministic derived key when appContext is provided and no key is set', async () => { + const tokenProvider: TokenProvider = {getToken: async () => 'access-token'} + const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com') + const {url} = await startServer({ + tokenProvider, + protectMutations: true, + appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'}, + }) + + const valid = await fetch(`${url}/graphiql/graphql.json?key=${derived}&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'mutation M { x { id } }'}), + }) + expect(valid.status).toBe(400) + const validBody = (await valid.json()) as {errors: {message: string}[]} + expect(validBody.errors[0]?.message).toMatch(/mutations are disabled/i) + + const invalid = await fetch(`${url}/graphiql/graphql.json?key=wrong&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: 'mutation M { x { id } }'}), + }) + expect(invalid.status).toBe(404) + }) + + test('generates a random per-process key when no appContext and no key are provided', async () => { + const tokenProvider: TokenProvider = {getToken: async () => 'access-token'} + const {url} = await startServer({tokenProvider}) + + const response = await fetch(`${url}/graphiql/graphql.json?key=anything&api_version=2024-10`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({query: '{ shop { name } }'}), + }) + + // We don't know the key; hitting the endpoint with an arbitrary key should 404. + expect(response.status).toBe(404) + }) + + test('renders app install guidance for unauthorized app GraphiQL sessions', async () => { + const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))} + const {url} = await startServer({ + tokenProvider, + appContext: {appName: 'My App', appUrl: 'https://example.com', apiSecret: 'app-secret'}, + }) + const derived = deriveGraphiQLKey('app-secret', 'store.myshopify.com') + + const response = await fetch(`${url}/graphiql?key=${derived}`) + const body = await response.text() + + expect(body).toContain('Install your app to access GraphiQL') + expect(body).toContain('Install your app') + expect(body).not.toContain('shopify store auth --store store.myshopify.com') + }) + + test('renders store auth guidance for unauthorized app-less GraphiQL sessions', async () => { + const tokenProvider: TokenProvider = {getToken: async () => Promise.reject(new Error('No token'))} + const {url} = await startServer({tokenProvider, key: 'k'}) + + const response = await fetch(`${url}/graphiql?key=k`) + const body = await response.text() + + expect(body).toContain('Reconnect store authentication to access GraphiQL') + expect(body).toContain('The GraphiQL Explorer couldn't access this store with the stored authentication.') + expect(body).toContain('shopify store auth --store store.myshopify.com') + expect(body).toContain('GraphiQL Explorer - Authentication Required') + expect(body).not.toContain('Install your app to access GraphiQL') + expect(body).not.toContain('id="app-install-button"') + }) +}) diff --git a/packages/cli-kit/src/public/node/graphiql/server.ts b/packages/cli-kit/src/public/node/graphiql/server.ts index 4b83473910d..cffeebcb819 100644 --- a/packages/cli-kit/src/public/node/graphiql/server.ts +++ b/packages/cli-kit/src/public/node/graphiql/server.ts @@ -8,6 +8,7 @@ import {adminUrl, supportedApiVersions} from '../api/admin.js' import {fetch} from '../http.js' import {renderLiquidTemplate} from '../liquid.js' import {outputDebug} from '../output.js' +import {containsMutation} from '../graphql.js' import { createApp, createRouter, @@ -20,7 +21,7 @@ import { setResponseStatus, toNodeListener, } from 'h3' -import {createHmac} from 'crypto' +import {createHmac, randomBytes} from 'crypto' import {createServer, Server} from 'http' import {readFileSync} from 'fs' import {Writable} from 'stream' @@ -61,64 +62,87 @@ class TokenRefreshError extends AbortError { } } -interface SetupGraphiQLServerOptions { - stdout: Writable - port: number +/** + * Pluggable strategy for obtaining and refreshing the Admin API access token + * that the GraphiQL proxy injects into every request. + * + * - `getToken` may return a cached token; the proxy calls it for every request. + * - `refreshToken` (optional) is invoked when the upstream Admin API returns 401. + * When omitted, the proxy falls back to calling `getToken` again on 401. + * + * Implementations must throw `TokenRefreshError` (or any thrown error) when the + * token cannot be obtained; the proxy renders the unauthorized template in that case. + */ +export interface TokenProvider { + getToken: () => Promise + refreshToken?: () => Promise +} + +/** + * Optional app-specific context, used to render the app pill and scopes note in the + * GraphiQL header and to drive the deterministic key derivation. Pass when the GraphiQL + * server is hosted as part of `shopify app dev`; omit for app-less use cases such as + * `shopify store execute`. + */ +export interface GraphiQLAppContext { appName: string appUrl: string - apiKey: string apiSecret: string - key?: string +} + +export interface SetupGraphiQLServerOptions { + stdout: Writable + port: number storeFqdn: string + tokenProvider: TokenProvider + /** + * Authentication key required as a `?key=` query string on every request. When omitted: + * - if `appContext` is provided, derived deterministically from `apiSecret` + `storeFqdn` + * so browser tabs survive dev server restarts. + * - otherwise, generated randomly per process. + */ + key?: string + appContext?: GraphiQLAppContext + /** + * When true, the proxy rejects mutation operations with HTTP 400 before forwarding + * them to the Admin API. Use this to mirror non-interactive safety guarantees in the + * interactive UI. + */ + protectMutations?: boolean } +export const MUTATIONS_BLOCKED_MESSAGE = 'Mutations are disabled. Re-run with --allow-mutations to enable mutations.' + /** * Starts a local HTTP server that hosts the GraphiQL UI and proxies requests to the - * Admin API for the configured store. The server uses the OAuth `client_credentials` - * grant with the supplied `apiKey` / `apiSecret` to mint and refresh access tokens - * on the fly. + * Admin API for the configured store. Authentication is delegated to the supplied + * `tokenProvider`, so the same server can serve both `shopify app dev` and stored-session + * use cases. * * @param options - Configuration for the server, including the target store, the - * Partners app credentials, and the local port to bind to. + * pluggable token provider, and the local port to bind to. * @returns The underlying Node `http.Server` instance, already listening on `options.port`. */ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server { - const {stdout, port, appName, appUrl, apiKey, apiSecret, key: providedKey, storeFqdn} = options - // Always require an authentication key. If not explicitly provided, derive one - // deterministically from apiSecret + storeFqdn so the key is stable across restarts - // (browser tabs survive dev server restarts) but not guessable without the app secret. - const key = resolveGraphiQLKey(providedKey, apiSecret, storeFqdn) + const {stdout, port, storeFqdn, tokenProvider, key: providedKey, appContext, protectMutations = false} = options + const key = resolveGraphiQLServerKey(providedKey, appContext, storeFqdn) outputDebug(`Setting up GraphiQL HTTP server on port ${port}...`, stdout) const app = createApp() const router = createRouter() - let _token: string | undefined - async function token(): Promise { - // eslint-disable-next-line require-atomic-updates - _token ??= await refreshToken() - return _token - } - - async function refreshToken(): Promise { + const refreshUpstreamToken = async (): Promise => { try { outputDebug('refreshing token', stdout) - _token = undefined - const bodyData = { - client_id: apiKey, - client_secret: apiSecret, - grant_type: 'client_credentials', - } - const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(bodyData), - }) + return await (tokenProvider.refreshToken ?? tokenProvider.getToken)() + } catch (_error) { + throw new TokenRefreshError() + } + } - const tokenJson = (await tokenResponse.json()) as {access_token: string} - return tokenJson.access_token + const currentToken = async (): Promise => { + try { + return await tokenProvider.getToken() } catch (_error) { throw new TokenRefreshError() } @@ -126,8 +150,8 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server async function fetchApiVersionsWithTokenRefresh(): Promise { return performActionWithRetryAfterRecovery( - async () => supportedApiVersions({storeFqdn, token: await token()}), - refreshToken, + async () => supportedApiVersions({storeFqdn, token: await currentToken()}), + refreshUpstreamToken, ) } @@ -174,7 +198,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server defineEventHandler(async () => { try { await fetchApiVersionsWithTokenRefresh() - return {status: 'OK', storeFqdn, appName, appUrl} + return {status: 'OK', storeFqdn, appName: appContext?.appName, appUrl: appContext?.appUrl} // eslint-disable-next-line no-catch-all/no-catch-all } catch { return {status: 'UNAUTHENTICATED'} @@ -204,8 +228,9 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server apiVersions = await fetchApiVersionsWithTokenRefresh() } catch (err) { if (err instanceof TokenRefreshError) { - return renderLiquidTemplate(unauthorizedTemplate, { - previewUrl: appUrl, + return renderLiquidTemplate(unauthorizedTemplate({hasAppContext: Boolean(appContext)}), { + previewUrl: appContext?.appUrl ?? '', + storeFqdn, url, }) } @@ -225,10 +250,11 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server graphiqlTemplate({ apiVersion, apiVersions: [...apiVersions, 'unstable'], - appName, - appUrl, + appName: appContext?.appName, + appUrl: appContext?.appUrl, key, storeFqdn, + protectMutations, }), { url, @@ -255,17 +281,23 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server const graphqlUrl = adminUrl(storeFqdn, query.api_version as string) try { const body = await readBody(event) + + if (protectMutations && isMutationRequestBody(body)) { + setResponseStatus(event, 400) + return {errors: [{message: MUTATIONS_BLOCKED_MESSAGE}]} + } + const reqBody = JSON.stringify(body) const reqHeaders = getRequestHeaders(event) const customHeaders = filterCustomHeaders(reqHeaders) - const runRequest = async () => { + const runRequest = async (token: string) => { const headers = { ...customHeaders, Accept: 'application/json', 'Content-Type': 'application/json', - 'X-Shopify-Access-Token': await token(), + 'X-Shopify-Access-Token': token, 'User-Agent': `ShopifyCLIGraphiQL/${CLI_KIT_VERSION}`, } @@ -276,11 +308,10 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server }) } - let result = await runRequest() + let result = await runRequest(await currentToken()) if (result.status === 401) { outputDebug('Token expired, fetching new token', stdout) - await refreshToken() - result = await runRequest() + result = await runRequest(await refreshUpstreamToken()) } setResponseHeader(event, 'Content-Type', 'application/json') @@ -303,3 +334,26 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server server.listen(port, 'localhost', () => stdout.write(`GraphiQL server started on port ${port}`)) return server } + +// Picks the right key based on what the caller supplied: +// - explicit non-empty key → use it +// - app context with apiSecret → derive deterministically (stable across restarts) +// - otherwise → random per-process key (browser tabs won't survive restarts, which is +// the right tradeoff when there's no stable secret to derive from). +function resolveGraphiQLServerKey( + providedKey: string | undefined, + appContext: GraphiQLAppContext | undefined, + storeFqdn: string, +): string { + const trimmed = providedKey?.trim() + if (trimmed) return trimmed + if (appContext) return deriveGraphiQLKey(appContext.apiSecret, storeFqdn) + return randomBytes(32).toString('hex') +} + +function isMutationRequestBody(body: unknown): boolean { + if (typeof body !== 'object' || body === null) return false + const {query, operationName} = body as {query?: unknown; operationName?: unknown} + if (typeof query !== 'string') return false + return containsMutation(query, typeof operationName === 'string' ? operationName : undefined) +} diff --git a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx index edc3638df34..8fc098ec9b8 100644 --- a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx +++ b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx @@ -1,4 +1,5 @@ import {platformAndArch} from '../../os.js' +import {MUTATIONS_BLOCKED_MESSAGE} from '../server.js' import React from 'react' import {renderToStaticMarkup} from 'react-dom/server' import {AppProvider, Badge, Banner, BlockStack, Box, Grid, InlineStack, Link, Select, Text} from '@shopify/polaris' @@ -49,10 +50,11 @@ export const defaultQuery = `query shopInfo { interface GraphiQLTemplateOptions { apiVersion: string apiVersions: string[] - appName: string - appUrl: string + appName?: string + appUrl?: string key: string storeFqdn: string + protectMutations?: boolean } export function graphiqlTemplate({ @@ -62,7 +64,10 @@ export function graphiqlTemplate({ appUrl, key, storeFqdn, + protectMutations = false, }: GraphiQLTemplateOptions): string { + const hasAppContext = Boolean(appName && appUrl) + const unauthorizedLabel = hasAppContext ? 'App uninstalled' : 'Auth invalid' return ` @@ -193,7 +198,7 @@ export function graphiqlTemplate({
Status: - App uninstalled + {unauthorizedLabel}
@@ -219,7 +224,7 @@ export function graphiqlTemplate({
- GraphiQL runs on the same access scopes you've defined in the TOML file for your app. + {scopesNoteText({hasAppContext, protectMutations})}
@@ -325,15 +330,25 @@ export function graphiqlTemplate({ const {status, storeFqdn, appName, appUrl} = await response.json() appIsInstalled = status === 'OK' if (storeFqdn) { - document.getElementById('outbound-links').innerHTML = \`${renderToStaticMarkup( - // Create HTML string with substitutions included - - { - // eslint-disable-next-line no-template-curly-in-string - linkPills({storeFqdn: '${storeFqdn}', appName: '${appName}', appUrl: '${appUrl}'}) - } - , - )}\` + ${ + hasAppContext + ? `document.getElementById('outbound-links').innerHTML = \`${renderToStaticMarkup( + + { + // eslint-disable-next-line no-template-curly-in-string + linkPills({storeFqdn: '${storeFqdn}', appName: '${appName}', appUrl: '${appUrl}'}) + } + , + )}\`` + : `document.getElementById('outbound-links').innerHTML = \`${renderToStaticMarkup( + + { + // eslint-disable-next-line no-template-curly-in-string + linkPills({storeFqdn: '${storeFqdn}'}) + } + , + )}\`` + } } }) }, 5000) @@ -345,8 +360,8 @@ export function graphiqlTemplate({ interface LinkPillOptions { storeFqdn: string - appName: string - appUrl: string + appName?: string + appUrl?: string } function linkPills({storeFqdn, appName, appUrl}: LinkPillOptions) { @@ -356,10 +371,30 @@ function linkPills({storeFqdn, appName, appUrl}: LinkPillOptions) { - App: - - - + {appName && appUrl ? ( + <> + App: + + + + + ) : null}
) } + +function scopesNoteText({ + hasAppContext, + protectMutations, +}: { + hasAppContext: boolean + protectMutations: boolean +}): string { + if (protectMutations) { + return MUTATIONS_BLOCKED_MESSAGE + } + if (hasAppContext) { + return "GraphiQL runs on the same access scopes you've defined in the TOML file for your app." + } + return 'GraphiQL runs with the access scopes granted to the stored app authentication for this store.' +} diff --git a/packages/cli-kit/src/public/node/graphiql/templates/unauthorized.tsx b/packages/cli-kit/src/public/node/graphiql/templates/unauthorized.tsx index 822a9d7a53e..adfb9057e2d 100644 --- a/packages/cli-kit/src/public/node/graphiql/templates/unauthorized.tsx +++ b/packages/cli-kit/src/public/node/graphiql/templates/unauthorized.tsx @@ -48,47 +48,72 @@ const shopifySvg = ( ) -const polarisUnauthorizedContent = renderToStaticMarkup( - - -
- - - {shopifySvg} -
- - - Install your app to access GraphiQL - -

The GraphiQL Explorer relies on your app being installed on your dev store to access its data.

-

- -

-
-
+interface UnauthorizedTemplateOptions { + hasAppContext: boolean +} -
- - - Loading GraphiQL... - -

- If you're not redirected automatically, click here. -

-
-
-
-
-
-
-
, -) +function polarisUnauthorizedContent({hasAppContext}: UnauthorizedTemplateOptions) { + return renderToStaticMarkup( + + +
+ + + {shopifySvg} +
{hasAppContext ? : }
+ +
+ + + Loading GraphiQL... + +

+ If you're not redirected automatically, click here. +

+
+
+
+
+
+
+
, + ) +} + +function AppUnauthorizedContent() { + return ( + + + Install your app to access GraphiQL + +

The GraphiQL Explorer relies on your app being installed on your dev store to access its data.

+

+ +

+
+ ) +} + +function StoreUnauthorizedContent() { + return ( + + + Reconnect store authentication to access GraphiQL + +

The GraphiQL Explorer couldn't access this store with the stored authentication.

+

+ Run {'shopify store auth --store {{storeFqdn}}'}, then refresh this page. +

+
+ ) +} -export const unauthorizedTemplate = ` +export function unauthorizedTemplate({hasAppContext}: UnauthorizedTemplateOptions): string { + return ` - GraphiQL Explorer - App Not Installed + GraphiQL Explorer - Authentication Required @@ -133,14 +158,16 @@ export const unauthorizedTemplate = ` } document.addEventListener("DOMContentLoaded", function() { - document.getElementById('app-install-button').onclick = openAppInstallTab + const installButton = document.getElementById('app-install-button') + if (installButton) installButton.onclick = openAppInstallTab })
- ${polarisUnauthorizedContent} + ${polarisUnauthorizedContent({hasAppContext})}
` +} diff --git a/packages/cli-kit/src/public/node/graphql.test.ts b/packages/cli-kit/src/public/node/graphql.test.ts new file mode 100644 index 00000000000..ecce224e994 --- /dev/null +++ b/packages/cli-kit/src/public/node/graphql.test.ts @@ -0,0 +1,64 @@ +import {containsMutation} from './graphql.js' +import {describe, expect, test} from 'vitest' + +describe('containsMutation', () => { + test('returns false for a query', () => { + expect(containsMutation('query Shop { shop { name } }')).toBe(false) + }) + + test('returns false for an anonymous query', () => { + expect(containsMutation('{ shop { name } }')).toBe(false) + }) + + test('returns true for a mutation', () => { + expect(containsMutation('mutation UpdateShop { shopUpdate(input: {}) { id } }')).toBe(true) + }) + + test('returns false for a subscription', () => { + expect(containsMutation('subscription Foo { foo { id } }')).toBe(false) + }) + + test('returns false for invalid GraphQL', () => { + expect(containsMutation('this is not graphql')).toBe(false) + }) + + test('returns false for an empty string', () => { + expect(containsMutation('')).toBe(false) + }) + + test('returns false for a fragment-only document', () => { + expect(containsMutation('fragment Foo on Shop { name }')).toBe(false) + }) + + test('with operationName, only checks the named operation', () => { + // eslint-disable-next-line @shopify/cli/no-inline-graphql + const document = ` + query Q { shop { name } } + mutation M { shopUpdate(input: {}) { id } } + ` + expect(containsMutation(document, 'Q')).toBe(false) + expect(containsMutation(document, 'M')).toBe(true) + }) + + test('with operationName not in document, returns false', () => { + const document = 'query Q { shop { name } }' + expect(containsMutation(document, 'DoesNotExist')).toBe(false) + }) + + test('without operationName but multiple operations, returns true if any is a mutation', () => { + // eslint-disable-next-line @shopify/cli/no-inline-graphql + const document = ` + query Q { shop { name } } + mutation M { shopUpdate(input: {}) { id } } + ` + expect(containsMutation(document)).toBe(true) + }) + + test('without operationName but multiple queries, returns false', () => { + const document = ` + query Q1 { shop { name } } + query Q2 { shop { id } } + ` + expect(containsMutation(document)).toBe(false) + }) +}) diff --git a/packages/cli-kit/src/public/node/graphql.ts b/packages/cli-kit/src/public/node/graphql.ts new file mode 100644 index 00000000000..82fd931f05d --- /dev/null +++ b/packages/cli-kit/src/public/node/graphql.ts @@ -0,0 +1,46 @@ +import {OperationDefinitionNode, parse} from 'graphql' + +/** + * Returns true if the GraphQL document contains a mutation operation that + * would actually be executed for the given (optional) operation name. + * + * - When `operationName` is provided, only the matching operation is checked. + * - When `operationName` is omitted and the document has a single operation, + * that operation is checked. + * - When the document has multiple operations and no operation name is given, + * any mutation in the document is treated as a mutation request (the GraphQL + * server would reject the ambiguous request anyway). + * + * Returns false for queries, subscriptions, fragment-only documents, and any + * input that fails to parse as GraphQL. + * + * @param query - The GraphQL document to inspect. + * @param operationName - Optional name of the operation to check; when set, only that operation is considered. + * @returns True if the relevant operation is a mutation; false otherwise. + */ +export function containsMutation(query: string, operationName?: string): boolean { + let document + try { + document = parse(query) + // eslint-disable-next-line no-catch-all/no-catch-all -- swallowing parse errors is the entire purpose + } catch { + return false + } + + const operations = document.definitions.filter( + (definition): definition is OperationDefinitionNode => definition.kind === 'OperationDefinition', + ) + + if (operations.length === 0) return false + + if (operationName) { + const target = operations.find((operation) => operation.name?.value === operationName) + return target?.operation === 'mutation' + } + + if (operations.length === 1) { + return operations[0]!.operation === 'mutation' + } + + return operations.some((operation) => operation.operation === 'mutation') +}