Skip to content

Commit 685bb7c

Browse files
chore(app): extract GraphiQL client-credentials token provider, drop unused deps
Now that @shopify/cli-kit owns the GraphiQL server, packages/app no longer needs Polaris, react-dom, or their type packages. - Move the client_credentials TokenProvider out of graphiql.ts into its own graphiql-token-provider.ts so it has a clear seam and is testable in isolation. - Add unit tests for createClientCredentialsTokenProvider covering caching semantics, forced refresh, and the OAuth request shape. - Drop @shopify/polaris, @shopify/polaris-icons, react-dom, and @types/react-dom from packages/app/package.json. h3 stays because the extension dev server still uses it. react / @types/react stay for the Ink-based dev UI.
1 parent de745ff commit 685bb7c

5 files changed

Lines changed: 123 additions & 54 deletions

File tree

packages/app/package.json

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,6 @@
5252
"@oclif/core": "4.5.3",
5353
"@shopify/cli-kit": "3.93.0",
5454
"@shopify/plugin-cloudflare": "3.93.0",
55-
"@shopify/polaris": "12.27.0",
56-
"@shopify/polaris-icons": "8.11.1",
5755
"@shopify/theme": "3.93.0",
5856
"@shopify/theme-check-node": "3.25.0",
5957
"@shopify/toml-patch": "0.3.0",
@@ -68,15 +66,13 @@
6866
"prettier": "3.8.1",
6967
"proper-lockfile": "4.1.2",
7068
"react": "19.2.4",
71-
"react-dom": "19.2.4",
7269
"which": "4.0.0",
7370
"ws": "8.18.0"
7471
},
7572
"devDependencies": {
7673
"@types/diff": "^5.0.3",
7774
"@types/proper-lockfile": "4.1.4",
7875
"@types/react": "^19.0.0",
79-
"@types/react-dom": "^19.0.0",
8076
"@types/which": "3.0.4",
8177
"@types/ws": "^8.5.13",
8278
"@vitest/coverage-istanbul": "^3.1.4"
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
2+
import {fetch} from '@shopify/cli-kit/node/http'
3+
import {beforeEach, describe, expect, test, vi} from 'vitest'
4+
5+
vi.mock('@shopify/cli-kit/node/http')
6+
7+
const mockedFetch = vi.mocked(fetch)
8+
9+
function mockTokenResponse(token: string) {
10+
mockedFetch.mockResolvedValueOnce({
11+
json: async () => ({access_token: token}),
12+
} as unknown as Awaited<ReturnType<typeof fetch>>)
13+
}
14+
15+
describe('createClientCredentialsTokenProvider', () => {
16+
beforeEach(() => {
17+
mockedFetch.mockReset()
18+
})
19+
20+
test('mints a token on first getToken call and caches it', async () => {
21+
mockTokenResponse('first-token')
22+
23+
const provider = createClientCredentialsTokenProvider({
24+
apiKey: 'api-key',
25+
apiSecret: 'api-secret',
26+
storeFqdn: 'store.myshopify.com',
27+
})
28+
29+
expect(await provider.getToken()).toBe('first-token')
30+
expect(await provider.getToken()).toBe('first-token')
31+
expect(mockedFetch).toHaveBeenCalledTimes(1)
32+
})
33+
34+
test('refreshToken always re-mints, even when a cached token exists', async () => {
35+
mockTokenResponse('first-token')
36+
mockTokenResponse('second-token')
37+
38+
const provider = createClientCredentialsTokenProvider({
39+
apiKey: 'api-key',
40+
apiSecret: 'api-secret',
41+
storeFqdn: 'store.myshopify.com',
42+
})
43+
44+
expect(await provider.getToken()).toBe('first-token')
45+
expect(await provider.refreshToken!()).toBe('second-token')
46+
expect(await provider.getToken()).toBe('second-token')
47+
expect(mockedFetch).toHaveBeenCalledTimes(2)
48+
})
49+
50+
test('posts the OAuth client_credentials body to the store admin endpoint', async () => {
51+
mockTokenResponse('token')
52+
53+
const provider = createClientCredentialsTokenProvider({
54+
apiKey: 'api-key',
55+
apiSecret: 'api-secret',
56+
storeFqdn: 'store.myshopify.com',
57+
})
58+
await provider.getToken()
59+
60+
expect(mockedFetch).toHaveBeenCalledWith(
61+
'https://store.myshopify.com/admin/oauth/access_token',
62+
expect.objectContaining({
63+
method: 'POST',
64+
headers: {'Content-Type': 'application/json'},
65+
body: JSON.stringify({
66+
client_id: 'api-key',
67+
client_secret: 'api-secret',
68+
grant_type: 'client_credentials',
69+
}),
70+
}),
71+
)
72+
})
73+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
2+
import {fetch} from '@shopify/cli-kit/node/http'
3+
4+
interface ClientCredentialsTokenProviderOptions {
5+
apiKey: string
6+
apiSecret: string
7+
storeFqdn: string
8+
}
9+
10+
/**
11+
* Returns a `TokenProvider` that mints Admin API tokens via OAuth `client_credentials`
12+
* using a Partners app's `apiKey` + `apiSecret`. Tokens are cached in-memory and
13+
* re-minted on demand when `refreshToken` is called (e.g. on a 401 from upstream).
14+
*
15+
* This is the strategy used by `shopify app dev`'s GraphiQL server. It assumes the app
16+
* is installed on the target store and that the app secret can mint a fresh token at any time.
17+
*/
18+
export function createClientCredentialsTokenProvider({
19+
apiKey,
20+
apiSecret,
21+
storeFqdn,
22+
}: ClientCredentialsTokenProviderOptions): TokenProvider {
23+
let cachedToken: string | undefined
24+
25+
const mint = async (): Promise<string> => {
26+
const tokenResponse = await fetch(`https://${storeFqdn}/admin/oauth/access_token`, {
27+
method: 'POST',
28+
headers: {'Content-Type': 'application/json'},
29+
body: JSON.stringify({
30+
client_id: apiKey,
31+
client_secret: apiSecret,
32+
grant_type: 'client_credentials',
33+
}),
34+
})
35+
36+
const tokenJson = (await tokenResponse.json()) as {access_token: string}
37+
cachedToken = tokenJson.access_token
38+
return cachedToken
39+
}
40+
41+
return {
42+
getToken: async () => cachedToken ?? mint(),
43+
refreshToken: async () => {
44+
cachedToken = undefined
45+
return mint()
46+
},
47+
}
48+
}

packages/app/src/cli/services/dev/processes/graphiql.ts

Lines changed: 2 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {BaseProcess, DevProcessFunction} from './types.js'
2-
import {setupGraphiQLServer, TokenProvider} from '@shopify/cli-kit/node/graphiql/server'
3-
import {fetch} from '@shopify/cli-kit/node/http'
2+
import {createClientCredentialsTokenProvider} from './graphiql-token-provider.js'
3+
import {setupGraphiQLServer} from '@shopify/cli-kit/node/graphiql/server'
44

55
interface GraphiQLServerProcessOptions {
66
appName: string
@@ -52,39 +52,3 @@ export const launchGraphiQLServer: DevProcessFunction<GraphiQLServerProcessOptio
5252
httpServer.close()
5353
})
5454
}
55-
56-
/**
57-
* In-memory token provider that mints Admin API tokens via OAuth `client_credentials`
58-
* using the Partners app's `apiKey` + `apiSecret`. Refreshes lazily and re-mints on demand.
59-
*/
60-
function createClientCredentialsTokenProvider(options: {
61-
apiKey: string
62-
apiSecret: string
63-
storeFqdn: string
64-
}): TokenProvider {
65-
let cachedToken: string | undefined
66-
67-
const mint = async (): Promise<string> => {
68-
const tokenResponse = await fetch(`https://${options.storeFqdn}/admin/oauth/access_token`, {
69-
method: 'POST',
70-
headers: {'Content-Type': 'application/json'},
71-
body: JSON.stringify({
72-
client_id: options.apiKey,
73-
client_secret: options.apiSecret,
74-
grant_type: 'client_credentials',
75-
}),
76-
})
77-
78-
const tokenJson = (await tokenResponse.json()) as {access_token: string}
79-
cachedToken = tokenJson.access_token
80-
return cachedToken
81-
}
82-
83-
return {
84-
getToken: async () => cachedToken ?? mint(),
85-
refreshToken: async () => {
86-
cachedToken = undefined
87-
return mint()
88-
},
89-
}
90-
}

pnpm-lock.yaml

Lines changed: 0 additions & 12 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)