Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 55 additions & 2 deletions packages/app/src/cli/services/dev/processes/graphiql.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -30,8 +31,60 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
{stdout, abortSignal},
options: GraphiQLServerProcessOptions,
) => {
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<string> => {
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()
},
}
}
140 changes: 138 additions & 2 deletions packages/cli-kit/src/public/node/graphiql/server.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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<void>((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)
})
})
Loading
Loading