diff --git a/packages/app/src/cli/services/dev/processes/graphiql.ts b/packages/app/src/cli/services/dev/processes/graphiql.ts index 06776b27e7..95d1b44054 100644 --- a/packages/app/src/cli/services/dev/processes/graphiql.ts +++ b/packages/app/src/cli/services/dev/processes/graphiql.ts @@ -1,5 +1,6 @@ import {BaseProcess, DevProcessFunction} from './types.js' -import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server' +import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server' +import {fetch} from '@shopify/cli-kit/node/http' interface GraphiQLServerProcessOptions { appName: string @@ -30,8 +31,60 @@ 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() }) } + +/** + * In-memory token provider that mints Admin API tokens via OAuth `client_credentials` + * using the Partners app's `apiKey` + `apiSecret`. Refreshes lazily and re-mints on demand. + */ +function createClientCredentialsTokenProvider(options: { + apiKey: string + apiSecret: string + storeFqdn: string +}): TokenProvider { + let cachedToken: string | undefined + + const mint = async (): Promise => { + const tokenResponse = await fetch(`https://${options.storeFqdn}/admin/oauth/access_token`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({ + client_id: options.apiKey, + client_secret: options.apiSecret, + grant_type: 'client_credentials', + }), + }) + + const tokenJson = (await tokenResponse.json()) as {access_token: string} + cachedToken = tokenJson.access_token + return cachedToken + } + + return { + getToken: async () => cachedToken ?? mint(), + refreshToken: async () => { + cachedToken = undefined + return mint() + }, + } +} 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 2a3a90460a..1876847a65 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,136 @@ 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, + 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).not.toBe(404) + + 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) + }) +}) diff --git a/packages/cli-kit/src/public/node/graphiql/server.ts b/packages/cli-kit/src/public/node/graphiql/server.ts index 4b83473910..196e513a45 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 } +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'} @@ -205,7 +229,7 @@ export function setupGraphiQLServer(options: SetupGraphiQLServerOptions): Server } catch (err) { if (err instanceof TokenRefreshError) { return renderLiquidTemplate(unauthorizedTemplate, { - previewUrl: appUrl, + previewUrl: appContext?.appUrl ?? '', url, }) } @@ -225,10 +249,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 +280,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 +307,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 +333,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 edc3638df3..0a2a2476dc 100644 --- a/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx +++ b/packages/cli-kit/src/public/node/graphiql/templates/graphiql.tsx @@ -49,10 +49,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 +63,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 +197,7 @@ export function graphiqlTemplate({
Status: - App uninstalled + {unauthorizedLabel}
@@ -219,7 +223,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 +329,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 +359,8 @@ export function graphiqlTemplate({ interface LinkPillOptions { storeFqdn: string - appName: string - appUrl: string + appName?: string + appUrl?: string } function linkPills({storeFqdn, appName, appUrl}: LinkPillOptions) { @@ -356,10 +370,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 are disabled. Re-run with --allow-mutations to enable mutations.' + } + 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/graphql.test.ts b/packages/cli-kit/src/public/node/graphql.test.ts new file mode 100644 index 0000000000..ecce224e99 --- /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 0000000000..82fd931f05 --- /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') +}